kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge remote-tracking branch 'origin/develop' into group-lookup
commit
c343cce5ea
|
@ -5,6 +5,7 @@ import { shouldHaveCard } from 'soapbox/utils/status';
|
|||
import api, { getNextLink } from '../api';
|
||||
|
||||
import { setComposeToStatus } from './compose';
|
||||
import { fetchGroupRelationships } from './groups';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modals';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
@ -124,6 +125,9 @@ const fetchStatus = (id: string) => {
|
|||
|
||||
return api(getState).get(`/api/v1/statuses/${id}`).then(({ data: status }) => {
|
||||
dispatch(importFetchedStatus(status));
|
||||
if (status.group) {
|
||||
dispatch(fetchGroupRelationships([status.group.id]));
|
||||
}
|
||||
dispatch({ type: STATUS_FETCH_SUCCESS, status, skipLoading });
|
||||
return status;
|
||||
}).catch(error => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { getSettings } from 'soapbox/actions/settings';
|
|||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { shouldFilter } from 'soapbox/utils/timelines';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
import api, { getNextLink, getPrevLink } from '../api';
|
||||
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
|
||||
|
@ -139,7 +139,7 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
|
|||
};
|
||||
|
||||
const replaceHomeTimeline = (
|
||||
accountId: string | null,
|
||||
accountId: string | undefined,
|
||||
{ maxId }: Record<string, any> = {},
|
||||
done?: () => void,
|
||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
|
@ -162,7 +162,12 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
return dispatch(noOpAsync());
|
||||
}
|
||||
|
||||
if (!params.max_id && !params.pinned && (timeline.items || ImmutableOrderedSet()).size > 0) {
|
||||
if (
|
||||
!params.max_id &&
|
||||
!params.pinned &&
|
||||
(timeline.items || ImmutableOrderedSet()).size > 0 &&
|
||||
!path.includes('max_id=')
|
||||
) {
|
||||
params.since_id = timeline.getIn(['items', 0]);
|
||||
}
|
||||
|
||||
|
@ -171,9 +176,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
|
||||
|
||||
return api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore));
|
||||
dispatch(expandTimelineSuccess(
|
||||
timelineId,
|
||||
response.data,
|
||||
getNextLink(response),
|
||||
getPrevLink(response),
|
||||
response.status === 206,
|
||||
isLoadingRecent,
|
||||
isLoadingMore,
|
||||
));
|
||||
done();
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
|
@ -181,9 +193,26 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
|||
});
|
||||
};
|
||||
|
||||
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => {
|
||||
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home';
|
||||
const params: any = { max_id: maxId };
|
||||
interface ExpandHomeTimelineOpts {
|
||||
accountId?: string
|
||||
maxId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface HomeTimelineParams {
|
||||
max_id?: string
|
||||
exclude_replies?: boolean
|
||||
with_muted?: boolean
|
||||
}
|
||||
|
||||
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
||||
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
|
||||
const params: HomeTimelineParams = {};
|
||||
|
||||
if (!url && maxId) {
|
||||
params.max_id = maxId;
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
params.exclude_replies = true;
|
||||
params.with_muted = true;
|
||||
|
@ -237,11 +266,20 @@ const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({
|
|||
skipLoading: !isLoadingMore,
|
||||
});
|
||||
|
||||
const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({
|
||||
const expandTimelineSuccess = (
|
||||
timeline: string,
|
||||
statuses: APIEntity[],
|
||||
next: string | undefined,
|
||||
prev: string | undefined,
|
||||
partial: boolean,
|
||||
isLoadingRecent: boolean,
|
||||
isLoadingMore: boolean,
|
||||
) => ({
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline,
|
||||
statuses,
|
||||
next,
|
||||
prev,
|
||||
partial,
|
||||
isLoadingRecent,
|
||||
skipLoading: !isLoadingMore,
|
||||
|
|
|
@ -181,7 +181,9 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
};
|
||||
|
||||
const getSiblings = () => {
|
||||
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current);
|
||||
return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[]))
|
||||
.filter(node => (node as HTMLDivElement).id !== 'toaster')
|
||||
.filter(node => node !== ref.current);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -536,7 +536,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
return menu;
|
||||
};
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.visibility);
|
||||
const publicStatus = ['public', 'unlisted', 'group'].includes(status.visibility);
|
||||
|
||||
const replyCount = status.replies_count;
|
||||
const reblogCount = status.reblogs_count;
|
||||
|
@ -609,7 +609,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const canShare = ('share' in navigator) && status.visibility === 'public';
|
||||
const canShare = ('share' in navigator) && (status.visibility === 'public' || status.visibility === 'group');
|
||||
|
||||
return (
|
||||
<HStack data-testid='status-action-bar'>
|
||||
|
|
|
@ -53,7 +53,7 @@ const StatusActionButton = React.forwardRef<HTMLButtonElement, IStatusActionButt
|
|||
src={icon}
|
||||
className={clsx(
|
||||
{
|
||||
'fill-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||
'fill-accent-300 text-accent-300 hover:fill-accent-300': active && filled && color === COLORS.accent,
|
||||
},
|
||||
iconClassName,
|
||||
)}
|
||||
|
|
|
@ -16,6 +16,7 @@ const messages = defineMessages({
|
|||
export type StreamfieldComponent<T> = React.ComponentType<{
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
autoFocus: boolean
|
||||
}>;
|
||||
|
||||
interface IStreamfield {
|
||||
|
@ -72,7 +73,12 @@ const Streamfield: React.FC<IStreamfield> = ({
|
|||
<Stack space={1}>
|
||||
{values.map((value, i) => value?._destroy ? null : (
|
||||
<HStack space={2} alignItems='center'>
|
||||
<Component key={i} onChange={handleChange(i)} value={value} />
|
||||
<Component
|
||||
key={i}
|
||||
onChange={handleChange(i)}
|
||||
value={value}
|
||||
autoFocus={i > 0}
|
||||
/>
|
||||
{values.length > minItems && onRemoveItem && (
|
||||
<IconButton
|
||||
iconClassName='h-4 w-4'
|
||||
|
|
|
@ -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,67 +1,88 @@
|
|||
import { TooltipPopup, useTooltip } from '@reach/tooltip';
|
||||
import React from 'react';
|
||||
|
||||
import Portal from '../portal/portal';
|
||||
|
||||
import './tooltip.css';
|
||||
import {
|
||||
arrow,
|
||||
FloatingArrow,
|
||||
FloatingPortal,
|
||||
offset,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useTransitionStyles,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
interface ITooltip {
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
/** Text to display in the tooltip. */
|
||||
text: string
|
||||
/** Element to display the tooltip around. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const centered = (triggerRect: any, tooltipRect: any) => {
|
||||
const triggerCenter = triggerRect.left + triggerRect.width / 2;
|
||||
const left = triggerCenter - tooltipRect.width / 2;
|
||||
const maxLeft = window.innerWidth - tooltipRect.width - 2;
|
||||
return {
|
||||
left: Math.min(Math.max(2, left), maxLeft) + window.scrollX,
|
||||
top: triggerRect.bottom + 8 + window.scrollY,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Tooltip
|
||||
*/
|
||||
const Tooltip: React.FC<ITooltip> = (props) => {
|
||||
const { children, text } = props;
|
||||
|
||||
/** Hoverable tooltip element. */
|
||||
const Tooltip: React.FC<ITooltip> = ({
|
||||
children,
|
||||
text,
|
||||
}) => {
|
||||
// get the props from useTooltip
|
||||
const [trigger, tooltip] = useTooltip();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// destructure off what we need to position the triangle
|
||||
const { isVisible, triggerRect } = tooltip;
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
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 (
|
||||
<React.Fragment>
|
||||
{React.cloneElement(children as any, trigger)}
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
})}
|
||||
|
||||
{isVisible && (
|
||||
// The Triangle. We position it relative to the trigger, not the popup
|
||||
// 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>
|
||||
{(isMounted) && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
data-reach-tooltip-arrow='true'
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
left:
|
||||
triggerRect && triggerRect.left - 10 + triggerRect.width / 2 as any,
|
||||
top: triggerRect && triggerRect.bottom + window.scrollY as any,
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
...styles,
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
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'
|
||||
{...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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
export default Tooltip;
|
|
@ -191,7 +191,14 @@ const SoapboxMount = () => {
|
|||
</BundleContainer>
|
||||
|
||||
<GdprBanner />
|
||||
<Toaster position='top-right' containerClassName='top-10' containerStyle={{ top: 75 }} />
|
||||
|
||||
<div id='toaster'>
|
||||
<Toaster
|
||||
position='top-right'
|
||||
containerClassName='top-10'
|
||||
containerStyle={{ top: 75 }}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</ScrollContext>
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
export enum Entities {
|
||||
ACCOUNTS = 'Accounts',
|
||||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_TAGS = 'GroupTags',
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses',
|
||||
STATUSES = 'Statuses'
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||
|
@ -23,7 +24,7 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
|||
const [isSubmitting, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity, AxiosError> = {}): Promise<void> {
|
||||
try {
|
||||
const result = await setPromise(entityFn(data));
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
|
@ -36,8 +37,12 @@ function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
|||
callbacks.onSuccess(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
if (error instanceof AxiosError) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,9 @@ interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
|||
}
|
||||
|
||||
interface EntityActionEndpoints {
|
||||
post?: string
|
||||
delete?: string
|
||||
patch?: string
|
||||
post?: string
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||
|
@ -30,10 +31,14 @@ function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
|||
const { createEntity, isSubmitting: createSubmitting } =
|
||||
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 {
|
||||
createEntity,
|
||||
deleteEntity,
|
||||
isSubmitting: createSubmitting || deleteSubmitting,
|
||||
updateEntity,
|
||||
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button, Form, FormActions, FormGroup, Input, Stack } from 'soapbox/components/ui';
|
||||
import { useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import ConsumersList from './consumers-list';
|
||||
|
||||
const messages = defineMessages({
|
||||
username: {
|
||||
id: 'login.fields.username_label',
|
||||
defaultMessage: 'Email or username',
|
||||
defaultMessage: 'E-mail or username',
|
||||
},
|
||||
email: {
|
||||
id: 'login.fields.email_label',
|
||||
defaultMessage: 'E-mail address',
|
||||
},
|
||||
password: {
|
||||
id: 'login.fields.password_placeholder',
|
||||
|
@ -24,6 +29,10 @@ interface ILoginForm {
|
|||
|
||||
const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const usernameLabel = intl.formatMessage(features.logInWithUsername ? messages.username : messages.email);
|
||||
const passwordLabel = intl.formatMessage(messages.password);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -33,10 +42,10 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
|||
|
||||
<Stack className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2' space={5}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.username)}>
|
||||
<FormGroup labelText={usernameLabel}>
|
||||
<Input
|
||||
aria-label={intl.formatMessage(messages.username)}
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
aria-label={usernameLabel}
|
||||
placeholder={usernameLabel}
|
||||
type='text'
|
||||
name='username'
|
||||
autoCorrect='off'
|
||||
|
@ -46,7 +55,7 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
|||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage(messages.password)}
|
||||
labelText={passwordLabel}
|
||||
hintText={
|
||||
<Link to='/reset-password' className='hover:underline'>
|
||||
<FormattedMessage
|
||||
|
@ -57,8 +66,8 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
|||
}
|
||||
>
|
||||
<Input
|
||||
aria-label={intl.formatMessage(messages.password)}
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
aria-label={passwordLabel}
|
||||
placeholder={passwordLabel}
|
||||
type='password'
|
||||
name='password'
|
||||
autoComplete='off'
|
||||
|
|
|
@ -4,17 +4,19 @@ import { Redirect } from 'react-router-dom';
|
|||
|
||||
import { resetPassword } from 'soapbox/actions/security';
|
||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
const messages = defineMessages({
|
||||
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'Email or username' },
|
||||
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'E-mail or username' },
|
||||
email: { id: 'password_reset.fields.email_placeholder', defaultMessage: 'E-mail address' },
|
||||
confirmation: { id: 'password_reset.confirmation', defaultMessage: 'Check your email for confirmation.' },
|
||||
});
|
||||
|
||||
const PasswordReset = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
@ -43,7 +45,7 @@ const PasswordReset = () => {
|
|||
|
||||
<div className='mx-auto sm:w-2/3 sm:pt-10 md:w-1/2'>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.nicknameOrEmail)}>
|
||||
<FormGroup labelText={intl.formatMessage(features.logInWithUsername ? messages.nicknameOrEmail : messages.email)}>
|
||||
<Input
|
||||
type='text'
|
||||
name='nickname_or_email'
|
||||
|
|
|
@ -30,7 +30,7 @@ const CarouselItem = React.forwardRef((
|
|||
setLoading(true);
|
||||
|
||||
if (isSelected) {
|
||||
dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
|
||||
dispatch(replaceHomeTimeline(undefined, { maxId: null }, () => setLoading(false)));
|
||||
|
||||
if (onPinned) {
|
||||
onPinned(null);
|
||||
|
|
|
@ -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;
|
|
@ -3,6 +3,8 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|||
|
||||
import { Input, Streamfield } from 'soapbox/components/ui';
|
||||
|
||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
||||
|
||||
const messages = defineMessages({
|
||||
hashtagPlaceholder: { id: 'manage_group.fields.hashtag_placeholder', defaultMessage: 'Add a topic' },
|
||||
});
|
||||
|
@ -30,12 +32,7 @@ const GroupTagsField: React.FC<IGroupTagsField> = ({ tags, onChange, onAddItem,
|
|||
);
|
||||
};
|
||||
|
||||
interface IHashtagField {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const HashtagField: React.FC<IHashtagField> = ({ value, onChange }) => {
|
||||
const HashtagField: StreamfieldComponent<string> = ({ value, onChange, autoFocus = false }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
|
||||
|
@ -49,6 +46,7 @@ const HashtagField: React.FC<IHashtagField> = ({ value, onChange }) => {
|
|||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Tex
|
|||
import { useAppSelector, useInstance } from 'soapbox/hooks';
|
||||
import { useGroup, useUpdateGroup } from 'soapbox/hooks/api';
|
||||
import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
||||
import AvatarPicker from './components/group-avatar-picker';
|
||||
|
@ -20,7 +21,7 @@ const messages = defineMessages({
|
|||
heading: { id: 'navigation_bar.edit_group', defaultMessage: 'Edit Group' },
|
||||
groupNamePlaceholder: { id: 'manage_group.fields.name_placeholder', defaultMessage: 'Group Name' },
|
||||
groupDescriptionPlaceholder: { id: 'manage_group.fields.description_placeholder', defaultMessage: 'Description' },
|
||||
success: { id: 'manage_group.success', defaultMessage: 'Group saved!' },
|
||||
groupSaved: { id: 'group.update.success', defaultMessage: 'Group successfully saved' },
|
||||
});
|
||||
|
||||
interface IEditGroup {
|
||||
|
@ -61,6 +62,17 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
|||
avatar: avatar.file,
|
||||
header: header.file,
|
||||
tags,
|
||||
}, {
|
||||
onSuccess() {
|
||||
toast.success(intl.formatMessage(messages.groupSaved));
|
||||
},
|
||||
onError(error) {
|
||||
const message = (error.response?.data as any)?.error;
|
||||
|
||||
if (error.response?.status === 422 && typeof message !== 'undefined') {
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
|
|
@ -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;
|
|
@ -5,9 +5,8 @@ import { useHistory } from 'react-router-dom';
|
|||
import { openModal } from 'soapbox/actions/modals';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
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 { useBackend } from 'soapbox/hooks/useBackend';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import toast from 'soapbox/toast';
|
||||
import { TRUTHSOCIAL } from 'soapbox/utils/features';
|
||||
|
|
|
@ -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;
|
|
@ -4,6 +4,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import PopularGroups from './components/discover/popular-groups';
|
||||
import PopularTags from './components/discover/popular-tags';
|
||||
import Search from './components/discover/search/search';
|
||||
import SuggestedGroups from './components/discover/suggested-groups';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
@ -71,6 +72,7 @@ const Discover: React.FC = () => {
|
|||
<>
|
||||
<PopularGroups />
|
||||
<SuggestedGroups />
|
||||
<PopularTags />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
@ -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;
|
|
@ -27,9 +27,10 @@ const HomeTimeline: React.FC = () => {
|
|||
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
||||
const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId as string | undefined);
|
||||
const currentAccountRelationship = useAppSelector(state => currentAccountId ? state.relationships.get(currentAccountId) : null);
|
||||
const next = useAppSelector(state => state.timelines.get('home')?.next);
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId }));
|
||||
dispatch(expandHomeTimeline({ url: next, maxId, accountId: currentAccountId }));
|
||||
};
|
||||
|
||||
// Mastodon generates the feed in Redis, and can return a partial timeline
|
||||
|
@ -52,7 +53,7 @@ const HomeTimeline: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId }));
|
||||
return dispatch(expandHomeTimeline({ accountId: currentAccountId }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { fetchInstance } from 'soapbox/actions/instance';
|
|||
import { openModal } from 'soapbox/actions/modals';
|
||||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
|
||||
import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus } from 'soapbox/hooks';
|
||||
import { useSoapboxConfig, useOwnAccount, useAppDispatch, useRegistrationStatus, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import Sonar from './sonar';
|
||||
|
||||
|
@ -18,7 +18,8 @@ const messages = defineMessages({
|
|||
home: { id: 'header.home.label', defaultMessage: 'Home' },
|
||||
login: { id: 'header.login.label', defaultMessage: 'Log in' },
|
||||
register: { id: 'header.register.label', defaultMessage: 'Register' },
|
||||
username: { id: 'header.login.username.placeholder', defaultMessage: 'Email or username' },
|
||||
username: { id: 'header.login.username.placeholder', defaultMessage: 'E-mail or username' },
|
||||
email: { id: 'header.login.email.placeholder', defaultMessage: 'E-mail address' },
|
||||
password: { id: 'header.login.password.label', defaultMessage: 'Password' },
|
||||
forgotPassword: { id: 'header.login.forgot_password', defaultMessage: 'Forgot password?' },
|
||||
});
|
||||
|
@ -26,6 +27,7 @@ const messages = defineMessages({
|
|||
const Header = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const account = useOwnAccount();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
@ -123,7 +125,7 @@ const Header = () => {
|
|||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value.trim())}
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
|
||||
className='max-w-[200px]'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
|
|
|
@ -2,20 +2,20 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||
import { FormattedDate, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import Account from 'soapbox/components/account';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
import StatusMedia from 'soapbox/components/status-media';
|
||||
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
|
||||
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 { 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 { getActualStatus } from 'soapbox/utils/status';
|
||||
|
||||
import StatusInteractionBar from './status-interaction-bar';
|
||||
|
||||
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 {
|
||||
status: StatusEntity
|
||||
|
@ -50,6 +50,35 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
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);
|
||||
if (!actualStatus) return null;
|
||||
const { account } = actualStatus;
|
||||
|
@ -75,14 +104,16 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
|
|||
}
|
||||
|
||||
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') {
|
||||
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 (
|
||||
<div className='border-box'>
|
||||
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
|
||||
{renderStatusInfo()}
|
||||
|
||||
<div className='mb-4'>
|
||||
<Account
|
||||
key={account.id}
|
||||
|
|
|
@ -52,7 +52,6 @@ import type {
|
|||
const messages = defineMessages({
|
||||
title: { id: 'status.title', defaultMessage: 'Post Details' },
|
||||
titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' },
|
||||
titleGroup: { id: 'status.title_group', defaultMessage: 'Group Post Details' },
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' },
|
||||
|
@ -526,7 +525,7 @@ const Thread: React.FC<IThread> = (props) => {
|
|||
|
||||
const titleMessage = () => {
|
||||
if (status.visibility === 'direct') return messages.titleDirect;
|
||||
return status.group ? messages.titleGroup : messages.title;
|
||||
return messages.title;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -35,7 +35,7 @@ const TestTimeline: React.FC = () => {
|
|||
|
||||
React.useEffect(() => {
|
||||
dispatch(importFetchedStatuses(MOCK_STATUSES));
|
||||
dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, null, false, false, false));
|
||||
dispatch(expandTimelineSuccess(timelineId, MOCK_STATUSES, undefined, undefined, false, false, false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
@ -184,7 +184,7 @@ const ComposeEventModal: React.FC<IComposeEventModal> = ({ onClose }) => {
|
|||
<Icon src={ADDRESS_ICONS[location.type] || require('@tabler/icons/map-pin.svg')} />
|
||||
<Stack className='grow'>
|
||||
<Text>{location.description}</Text>
|
||||
<Text theme='muted' size='xs'>{[location.street, location.locality, location.country].filter(val => val.trim()).join(' · ')}</Text>
|
||||
<Text theme='muted' size='xs'>{[location.street, location.locality, location.country].filter(val => val?.trim()).join(' · ')}</Text>
|
||||
</Stack>
|
||||
<IconButton title={intl.formatMessage(messages.resetLocation)} src={require('@tabler/icons/x.svg')} onClick={() => onChangeLocation(null)} />
|
||||
</HStack>
|
||||
|
|
|
@ -9,7 +9,7 @@ import { openSidebar } from 'soapbox/actions/sidebar';
|
|||
import SiteLogo from 'soapbox/components/site-logo';
|
||||
import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
|
||||
import Search from 'soapbox/features/compose/components/search';
|
||||
import { useAppDispatch, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useFeatures, useOwnAccount, useRegistrationStatus } from 'soapbox/hooks';
|
||||
|
||||
import ProfileDropdown from './profile-dropdown';
|
||||
|
||||
|
@ -17,7 +17,8 @@ import type { AxiosError } from 'axios';
|
|||
|
||||
const messages = defineMessages({
|
||||
login: { id: 'navbar.login.action', defaultMessage: 'Log in' },
|
||||
username: { id: 'navbar.login.username.placeholder', defaultMessage: 'Email or username' },
|
||||
username: { id: 'navbar.login.username.placeholder', defaultMessage: 'E-mail or username' },
|
||||
email: { id: 'navbar.login.email.placeholder', defaultMessage: 'E-mail address' },
|
||||
password: { id: 'navbar.login.password.label', defaultMessage: 'Password' },
|
||||
forgotPassword: { id: 'navbar.login.forgot_password', defaultMessage: 'Forgot password?' },
|
||||
});
|
||||
|
@ -25,6 +26,7 @@ const messages = defineMessages({
|
|||
const Navbar = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const { isOpen } = useRegistrationStatus();
|
||||
const account = useOwnAccount();
|
||||
const node = useRef(null);
|
||||
|
@ -111,7 +113,7 @@ const Navbar = () => {
|
|||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
|
||||
className='max-w-[200px]'
|
||||
/>
|
||||
|
||||
|
|
|
@ -124,8 +124,12 @@ import {
|
|||
GroupsDiscover,
|
||||
GroupsPopular,
|
||||
GroupsSuggested,
|
||||
GroupsTag,
|
||||
GroupsTags,
|
||||
PendingGroupRequests,
|
||||
GroupMembers,
|
||||
GroupTags,
|
||||
GroupTagTimeline,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
GroupBlockedMembers,
|
||||
|
@ -139,6 +143,8 @@ import { WrappedRoute } from './util/react-router-helpers';
|
|||
// Without this it ends up in ~8 very commonly used bundles.
|
||||
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);
|
||||
|
@ -306,7 +312,11 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
|
||||
{features.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.groupsTags && <WrappedRoute path='/groups/:groupId/tags' exact page={GroupPage} component={GroupTags} content={children} />}
|
||||
{features.groupsTags && <WrappedRoute path='/groups/:groupId/tag/:id' exact page={GroupsPendingPage} component={GroupTagTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:groupId' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:groupId/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:groupId/media' publicRoute={!authenticatedProfile} component={GroupGallery} page={GroupPage} content={children} />}
|
||||
|
@ -316,6 +326,8 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ 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.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} />}
|
||||
|
|
|
@ -562,6 +562,14 @@ export function GroupsSuggested() {
|
|||
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() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../groups/pending-requests');
|
||||
}
|
||||
|
@ -570,6 +578,14 @@ export function GroupMembers() {
|
|||
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() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-timeline');
|
||||
}
|
||||
|
|
|
@ -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 };
|
|
@ -1,8 +1,10 @@
|
|||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
||||
|
||||
|
@ -48,12 +50,24 @@ function useGroup(groupId: string, refetch = true) {
|
|||
|
||||
function useGroupRelationship(groupId: string) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return useEntity<GroupRelationship>(
|
||||
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRelationship?.id) {
|
||||
dispatch(fetchGroupRelationshipsSuccess([groupRelationship]));
|
||||
}
|
||||
}, [groupRelationship?.id]);
|
||||
|
||||
return {
|
||||
entity: groupRelationship,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
function useGroupRelationships(groupIds: string[]) {
|
||||
|
|
|
@ -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,17 +11,22 @@ export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest'
|
|||
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
||||
export { useDeleteGroup } from './groups/useDeleteGroup';
|
||||
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
||||
export { useGroupMedia } from './groups/useGroupMedia';
|
||||
export { useGroup, useGroups } from './groups/useGroups';
|
||||
export { useGroupMedia } from './groups/useGroupMedia';
|
||||
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
||||
export { useGroupSearch } from './groups/useGroupSearch';
|
||||
export { useGroupTag } from './groups/useGroupTag';
|
||||
export { useGroupTags } from './groups/useGroupTags';
|
||||
export { useGroupValidation } from './groups/useGroupValidation';
|
||||
export { useGroupsFromTag } from './groups/useGroupsFromTag';
|
||||
export { useJoinGroup } from './groups/useJoinGroup';
|
||||
export { useLeaveGroup } from './groups/useLeaveGroup';
|
||||
export { usePopularTags } from './groups/usePopularTags';
|
||||
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
|
||||
export { useUpdateGroup } from './groups/useUpdateGroup';
|
||||
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
export { useRelationships } from './useRelationships';
|
||||
export { useRelationships } from './useRelationships';
|
||||
|
|
|
@ -2,6 +2,7 @@ export { useAccount } from './useAccount';
|
|||
export { useApi } from './useApi';
|
||||
export { useAppDispatch } from './useAppDispatch';
|
||||
export { useAppSelector } from './useAppSelector';
|
||||
export { useBackend } from './useBackend';
|
||||
export { useClickOutside } from './useClickOutside';
|
||||
export { useCompose } from './useCompose';
|
||||
export { useDebounce } from './useDebounce';
|
||||
|
|
|
@ -809,8 +809,20 @@
|
|||
"group.tabs.all": "All",
|
||||
"group.tabs.media": "Media",
|
||||
"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.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.upload_banner": "Upload photo",
|
||||
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
||||
"groups.discover.popular.show_more": "Show More",
|
||||
|
@ -829,6 +841,10 @@
|
|||
"groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.",
|
||||
"groups.discover.suggested.show_more": "Show More",
|
||||
"groups.discover.suggested.title": "Suggested For You",
|
||||
"groups.discover.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.title": "No Groups yet",
|
||||
"groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}",
|
||||
|
@ -837,10 +853,12 @@
|
|||
"groups.pending.label": "Pending Requests",
|
||||
"groups.popular.label": "Suggested 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.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
"header.home.label": "Home",
|
||||
"header.login.email.placeholder": "E-mail address",
|
||||
"header.login.forgot_password": "Forgot password?",
|
||||
"header.login.label": "Log in",
|
||||
"header.login.password.label": "Password",
|
||||
|
@ -925,6 +943,7 @@
|
|||
"lists.subheading": "Your lists",
|
||||
"loading_indicator.label": "Loading…",
|
||||
"location_search.placeholder": "Find an address",
|
||||
"login.fields.email_label": "E-mail address",
|
||||
"login.fields.instance_label": "Instance",
|
||||
"login.fields.instance_placeholder": "example.com",
|
||||
"login.fields.otp_code_hint": "Enter the two-factor code generated by your phone app or use one of your recovery codes",
|
||||
|
@ -967,7 +986,6 @@
|
|||
"manage_group.privacy.private.label": "Private (Owner approval required)",
|
||||
"manage_group.privacy.public.hint": "Discoverable. Anyone can join.",
|
||||
"manage_group.privacy.public.label": "Public",
|
||||
"manage_group.success": "Group saved!",
|
||||
"manage_group.tagline": "Groups connect you with others based on shared interests.",
|
||||
"media_panel.empty_message": "No media found.",
|
||||
"media_panel.title": "Media",
|
||||
|
@ -1014,6 +1032,7 @@
|
|||
"mute_modal.duration": "Duration",
|
||||
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
||||
"navbar.login.action": "Log in",
|
||||
"navbar.login.email.placeholder": "E-mail address",
|
||||
"navbar.login.forgot_password": "Forgot password?",
|
||||
"navbar.login.password.label": "Password",
|
||||
"navbar.login.username.placeholder": "Email or username",
|
||||
|
@ -1116,6 +1135,7 @@
|
|||
"onboarding.suggestions.title": "Suggested accounts",
|
||||
"onboarding.view_feed": "View Feed",
|
||||
"password_reset.confirmation": "Check your email for confirmation.",
|
||||
"password_reset.fields.email_placeholder": "E-mail address",
|
||||
"password_reset.fields.username_placeholder": "Email or username",
|
||||
"password_reset.header": "Reset Password",
|
||||
"password_reset.reset": "Reset password",
|
||||
|
@ -1465,7 +1485,6 @@
|
|||
"status.show_original": "Show original",
|
||||
"status.title": "Post Details",
|
||||
"status.title_direct": "Direct message",
|
||||
"status.title_group": "Group Post Details",
|
||||
"status.translate": "Translate",
|
||||
"status.translated_from_with": "Translated from {lang} using {provider}",
|
||||
"status.unbookmark": "Remove bookmark",
|
||||
|
|
|
@ -1464,7 +1464,6 @@
|
|||
"status.show_original": "显示原文本",
|
||||
"status.title": "帖文详情",
|
||||
"status.title_direct": "私信",
|
||||
"status.title_group": "群组帖文详情",
|
||||
"status.translate": "翻译",
|
||||
"status.translated_from_with": "使用 {provider} 从 {lang} 翻译而来",
|
||||
"status.unbookmark": "移除书签",
|
||||
|
|
|
@ -25,7 +25,7 @@ describe('normalizeInstance()', () => {
|
|||
},
|
||||
groups: {
|
||||
max_characters_name: 50,
|
||||
max_characters_description: 100,
|
||||
max_characters_description: 160,
|
||||
},
|
||||
},
|
||||
description: '',
|
||||
|
|
|
@ -37,7 +37,7 @@ export const InstanceRecord = ImmutableRecord({
|
|||
}),
|
||||
groups: ImmutableMap<string, number>({
|
||||
max_characters_name: 50,
|
||||
max_characters_description: 100,
|
||||
max_characters_description: 160,
|
||||
}),
|
||||
}),
|
||||
description: '',
|
||||
|
|
|
@ -20,7 +20,7 @@ import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
|||
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
|
||||
|
||||
export type StatusApprovalStatus = 'pending' | 'approval' | 'rejected';
|
||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self';
|
||||
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'self' | 'group';
|
||||
|
||||
export type EventJoinMode = 'free' | 'restricted' | 'invite';
|
||||
export type EventJoinState = 'pending' | 'reject' | 'accept';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
|
@ -13,7 +13,7 @@ import {
|
|||
SignUpPanel,
|
||||
SuggestedGroupsPanel,
|
||||
} 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 { useGroupMembershipRequests } from 'soapbox/hooks/api/groups/useGroupMembershipRequests';
|
||||
import { Group } from 'soapbox/schemas';
|
||||
|
@ -24,6 +24,7 @@ const messages = defineMessages({
|
|||
all: { id: 'group.tabs.all', defaultMessage: 'All' },
|
||||
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
|
||||
media: { id: 'group.tabs.media', defaultMessage: 'Media' },
|
||||
tags: { id: 'group.tabs.tags', defaultMessage: 'Topics' },
|
||||
});
|
||||
|
||||
interface IGroupPage {
|
||||
|
@ -62,6 +63,7 @@ const BlockedBlankslate = ({ group }: { group: Group }) => (
|
|||
/** Page to display a group. */
|
||||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
const match = useRouteMatch();
|
||||
const me = useOwnAccount();
|
||||
|
||||
|
@ -74,13 +76,29 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
const isBlocked = group?.relationship?.blocked_by;
|
||||
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),
|
||||
to: `/group/${group?.slug}`,
|
||||
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),
|
||||
to: `/group/${group?.slug}/members`,
|
||||
name: '/group/:groupSlug/members',
|
||||
|
@ -89,9 +107,11 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
{
|
||||
text: intl.formatMessage(messages.media),
|
||||
to: `/group/${group?.slug}/media`,
|
||||
name: '/group/:groupSlug',
|
||||
},
|
||||
];
|
||||
name: '/group/:groupSlug/media',
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [features.groupsTags]);
|
||||
|
||||
const renderChildren = () => {
|
||||
if (!isMember && isPrivate) {
|
||||
|
@ -110,7 +130,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
|||
<GroupHeader group={group} />
|
||||
|
||||
<Tabs
|
||||
items={items}
|
||||
items={tabItems}
|
||||
activeItem={match.path}
|
||||
/>
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
|
|||
<>
|
||||
<Layout.Main className='space-y-3 pt-3 dark:divide-gray-800 sm:pt-0'>
|
||||
{me && (
|
||||
<Card variant='rounded' ref={composeBlock}>
|
||||
<Card className='relative z-[1]' variant='rounded' ref={composeBlock}>
|
||||
<CardBody>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${acct}`}>
|
||||
|
|
|
@ -46,6 +46,8 @@ const TimelineRecord = ImmutableRecord({
|
|||
top: true,
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
next: undefined as string | undefined,
|
||||
prev: undefined as string | undefined,
|
||||
items: ImmutableOrderedSet<string>(),
|
||||
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
||||
feedAccountId: null,
|
||||
|
@ -87,13 +89,23 @@ const setFailed = (state: State, timelineId: string, failed: boolean) => {
|
|||
return state.update(timelineId, TimelineRecord(), timeline => timeline.set('loadingFailed', failed));
|
||||
};
|
||||
|
||||
const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList<ImmutableMap<string, any>>, next: string | null, isPartial: boolean, isLoadingRecent: boolean) => {
|
||||
const expandNormalizedTimeline = (
|
||||
state: State,
|
||||
timelineId: string,
|
||||
statuses: ImmutableList<ImmutableMap<string, any>>,
|
||||
next: string | undefined,
|
||||
prev: string | undefined,
|
||||
isPartial: boolean,
|
||||
isLoadingRecent: boolean,
|
||||
) => {
|
||||
const newIds = getStatusIds(statuses);
|
||||
|
||||
return state.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => {
|
||||
timeline.set('isLoading', false);
|
||||
timeline.set('loadingFailed', false);
|
||||
timeline.set('isPartial', isPartial);
|
||||
timeline.set('next', next);
|
||||
timeline.set('prev', prev);
|
||||
|
||||
if (!next && !isLoadingRecent) timeline.set('hasMore', false);
|
||||
|
||||
|
@ -322,7 +334,15 @@ export default function timelines(state: State = initialState, action: AnyAction
|
|||
case TIMELINE_EXPAND_FAIL:
|
||||
return handleExpandFail(state, action.timeline);
|
||||
case TIMELINE_EXPAND_SUCCESS:
|
||||
return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList<ImmutableMap<string, any>>, action.next, action.partial, action.isLoadingRecent);
|
||||
return expandNormalizedTimeline(
|
||||
state,
|
||||
action.timeline,
|
||||
fromJS(action.statuses) as ImmutableList<ImmutableMap<string, any>>,
|
||||
action.next,
|
||||
action.prev,
|
||||
action.partial,
|
||||
action.isLoadingRecent,
|
||||
);
|
||||
case TIMELINE_UPDATE:
|
||||
return updateTimeline(state, action.timeline, action.statusId);
|
||||
case TIMELINE_UPDATE_QUEUE:
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
import z from 'zod';
|
||||
|
||||
const groupTagSchema = z.object({
|
||||
id: 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>;
|
||||
|
|
|
@ -6,6 +6,7 @@ export { customEmojiSchema } from './custom-emoji';
|
|||
export { groupSchema } from './group';
|
||||
export { groupMemberSchema } from './group-member';
|
||||
export { groupRelationshipSchema } from './group-relationship';
|
||||
export { groupTagSchema } from './group-tag';
|
||||
export { relationshipSchema } from './relationship';
|
||||
|
||||
/**
|
||||
|
@ -16,4 +17,5 @@ export type { CustomEmoji } from './custom-emoji';
|
|||
export type { Group } from './group';
|
||||
export type { GroupMember } from './group-member';
|
||||
export type { GroupRelationship } from './group-relationship';
|
||||
export type { GroupTag } from './group-tag';
|
||||
export type { Relationship } from './relationship';
|
||||
|
|
|
@ -559,6 +559,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
groupsSearch: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can see topics for Groups.
|
||||
*/
|
||||
groupsTags: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can validate group names.
|
||||
*/
|
||||
|
@ -597,6 +602,14 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === PLEROMA && gte(v.version, '0.9.9'),
|
||||
]),
|
||||
|
||||
/**
|
||||
* Can sign in using username instead of e-mail address.
|
||||
*/
|
||||
logInWithUsername: any([
|
||||
v.software === PLEROMA,
|
||||
v.software === TRUTHSOCIAL,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Can perform moderation actions with account and reports.
|
||||
* @see {@link https://docs.joinmastodon.org/methods/admin/}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@floating-ui/react": "^0.21.0",
|
||||
"@floating-ui/react": "^0.23.0",
|
||||
"@fontsource/inter": "^4.5.1",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
|
@ -61,7 +61,6 @@
|
|||
"@reach/popover": "^0.18.0",
|
||||
"@reach/rect": "^0.18.0",
|
||||
"@reach/tabs": "^0.18.0",
|
||||
"@reach/tooltip": "^0.18.0",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"@sentry/browser": "^7.37.2",
|
||||
"@sentry/react": "^7.37.2",
|
||||
|
|
27
yarn.lock
27
yarn.lock
|
@ -1751,10 +1751,10 @@
|
|||
dependencies:
|
||||
"@floating-ui/dom" "^1.2.1"
|
||||
|
||||
"@floating-ui/react@^0.21.0":
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.21.1.tgz#47cafdff0c79f5aa1067398ee06ea2144d22ea7a"
|
||||
integrity sha512-ojjsU/rvWEyNDproy1yQW5EDXJnDip8DXpSRh+hogPgZWEp0Y/2UBPxL3yoa53BDYsL+dqJY0osl9r0Jes3eeg==
|
||||
"@floating-ui/react@^0.23.0":
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.23.0.tgz#8b548235ac4478537757c90a66a3bac9068e29d8"
|
||||
integrity sha512-Id9zTLSjHtcCjBQm0Stc/fRUBGrnHurL/a1HrtQg8LvL6Ciw9KHma2WT++F17kEfhsPkA0UHYxmp+ijmAy0TCw==
|
||||
dependencies:
|
||||
"@floating-ui/react-dom" "^1.3.0"
|
||||
aria-hidden "^1.1.3"
|
||||
|
@ -2749,30 +2749,11 @@
|
|||
"@reach/polymorphic" "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":
|
||||
version "0.18.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.18.0.tgz#4f3cebe093dd436eeaff633809bf0f68f4f9d2ee"
|
||||
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":
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.1.tgz#94ee1981b8cf9227cda40163a04704a9544c9a9f"
|
||||
|
|
Ładowanie…
Reference in New Issue