Merge remote-tracking branch 'soapbox/develop' into lexical
This commit is contained in:
commit
5b55a89826
|
@ -0,0 +1,26 @@
|
|||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_7_1989" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="73" y="38" width="46" height="65">
|
||||
<path d="M95.9997 38C90.3542 38 85.7775 42.1122 85.7775 47.1848V56.3696C85.7775 59.2605 87.2924 61.9828 89.8664 63.7174C90.1516 63.9096 90.0851 64.347 89.7556 64.4456L81.7371 66.8472C76.8048 68.3245 73.3901 72.3258 73.0313 76.9334C72.9897 77.4671 73.0675 78.0013 73.2009 78.5197L79.5 103C106.943 103 115.635 97.2124 118.109 94.7132C118.778 94.0378 119 93.0929 119 92.1426V77.739C119 72.7973 115.481 68.4099 110.263 66.8472L102.244 64.4455C101.914 64.3468 101.848 63.9096 102.133 63.7174C104.707 61.9828 106.222 59.2605 106.222 56.3696V47.1848C106.222 42.1122 101.645 38 95.9997 38Z" fill="#5448EE"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_7_1989)">
|
||||
<path d="M95.9997 38C90.3542 38 85.7775 42.1122 85.7775 47.1848V56.3696C85.7775 59.2605 87.2924 61.9828 89.8664 63.7174C90.1516 63.9096 90.0851 64.347 89.7556 64.4456L81.7371 66.8472C76.8048 68.3245 73.3901 72.3258 73.0313 76.9334C72.9897 77.4671 73.0675 78.0013 73.2009 78.5197L79.5 103C106.943 103 115.635 97.2124 118.109 94.7132C118.778 94.0378 119 93.0929 119 92.1426V77.739C119 72.7973 115.481 68.4099 110.263 66.8472L102.244 64.4455C101.914 64.3468 101.848 63.9096 102.133 63.7174C104.707 61.9828 106.222 59.2605 106.222 56.3696V47.1848C106.222 42.1122 101.645 38 95.9997 38Z" fill="#5448EE"/>
|
||||
<path opacity="0.34" d="M79.4229 76L112.423 107.841L109.423 111.884H70.9998V77.3607L79.4229 76Z" fill="#322B4E"/>
|
||||
</g>
|
||||
<path d="M32.0003 38C37.6458 38 42.2225 42.1122 42.2225 47.1848V56.3696C42.2225 59.2605 40.7076 61.9828 38.1336 63.7174L37.4195 64.1986L46.2629 66.8472C51.4806 68.4099 55 72.7973 55 77.739L48.5 103C13.5 103 9 93.5862 9 93.5862V77.739C9 72.7973 12.5194 68.4099 17.7371 66.8472L26.5808 64.1985L25.8669 63.7174C23.2929 61.9828 21.778 59.2605 21.778 56.3696V47.1848C21.778 42.1122 26.3547 38 32.0003 38Z" fill="url(#paint0_linear_7_1989)"/>
|
||||
<mask id="mask1_7_1989" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="38" y="54" width="52" height="50">
|
||||
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V95.0005C89.6165 99.4188 86.0348 103.001 81.6165 103.001H46.3857C41.9675 103.001 38.3857 99.4188 38.3857 95.0005V73.7909Z" fill="#B0AEB8"/>
|
||||
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V95.0005C89.6165 99.4188 86.0348 103.001 81.6165 103.001H46.3857C41.9675 103.001 38.3857 99.4188 38.3857 95.0005V73.7909Z" fill="#B0AEB8"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_7_1989)">
|
||||
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V108.055H38.3857V73.7909Z" fill="#645F76"/>
|
||||
<path d="M38.3857 73.7909C38.3857 67.5992 42.3053 62.1022 48.1163 60.1442L64.0011 54.792L79.8859 60.1442C85.6969 62.1022 89.6165 67.5992 89.6165 73.7909V108.055H38.3857V73.7909Z" fill="#645F76"/>
|
||||
<path opacity="0.34" d="M90 86.7889L57 54.9479L60 50.9046H98.4231V85.4282L90 86.7889Z" fill="#322B4E"/>
|
||||
</g>
|
||||
<path d="M52.6162 35.3846C52.6162 29.0971 57.7133 24 64.0008 24C70.2884 24 75.3854 29.0971 75.3854 35.3846V47.1141C75.3854 50.6768 73.7177 54.034 70.8786 56.1863L69.1592 57.4899C66.109 59.8023 61.8926 59.8023 58.8425 57.4899L57.123 56.1863C54.284 54.034 52.6162 50.6768 52.6162 47.1141V35.3846Z" fill="#645F76"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_7_1989" x1="-49" y1="-16.2414" x2="19.0934" y2="125.345" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E4E2FC" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#B7B2F8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"note": "patriots 900000001",
|
||||
"discoverable": true,
|
||||
"id": "109989480368015378",
|
||||
"domain": null,
|
||||
"avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
|
||||
"avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
|
||||
"header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
|
||||
"header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
|
||||
"group_visibility": "everyone",
|
||||
"created_at": "2023-03-08T00:00:00.000Z",
|
||||
"display_name": "PATRIOT PATRIOTS",
|
||||
"membership_required": true,
|
||||
"members_count": 1,
|
||||
"tags": []
|
||||
}
|
|
@ -40,14 +40,6 @@ const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
|
|||
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
|
||||
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_JOIN_REQUEST = 'GROUP_JOIN_REQUEST';
|
||||
const GROUP_JOIN_SUCCESS = 'GROUP_JOIN_SUCCESS';
|
||||
const GROUP_JOIN_FAIL = 'GROUP_JOIN_FAIL';
|
||||
|
||||
const GROUP_LEAVE_REQUEST = 'GROUP_LEAVE_REQUEST';
|
||||
const GROUP_LEAVE_SUCCESS = 'GROUP_LEAVE_SUCCESS';
|
||||
const GROUP_LEAVE_FAIL = 'GROUP_LEAVE_FAIL';
|
||||
|
||||
const GROUP_DELETE_STATUS_REQUEST = 'GROUP_DELETE_STATUS_REQUEST';
|
||||
const GROUP_DELETE_STATUS_SUCCESS = 'GROUP_DELETE_STATUS_SUCCESS';
|
||||
const GROUP_DELETE_STATUS_FAIL = 'GROUP_DELETE_STATUS_FAIL';
|
||||
|
@ -148,7 +140,8 @@ const createGroup = (params: Record<string, any>, shouldReset?: boolean) =>
|
|||
if (shouldReset) {
|
||||
dispatch(resetGroupEditor());
|
||||
}
|
||||
dispatch(closeModal('MANAGE_GROUP'));
|
||||
|
||||
return data;
|
||||
}).catch(err => dispatch(createGroupFail(err)));
|
||||
};
|
||||
|
||||
|
@ -312,70 +305,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
|
|||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const joinGroup = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const locked = (getState().groups.items.get(id) as any).locked || false;
|
||||
|
||||
dispatch(joinGroupRequest(id, locked));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${id}/join`).then(response => {
|
||||
dispatch(joinGroupSuccess(response.data));
|
||||
toast.success(locked ? messages.joinRequestSuccess : messages.joinSuccess);
|
||||
}).catch(error => {
|
||||
dispatch(joinGroupFail(error, locked));
|
||||
});
|
||||
};
|
||||
|
||||
const leaveGroup = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(leaveGroupRequest(id));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${id}/leave`).then(response => {
|
||||
dispatch(leaveGroupSuccess(response.data));
|
||||
toast.success(messages.leaveSuccess);
|
||||
}).catch(error => {
|
||||
dispatch(leaveGroupFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const joinGroupRequest = (id: string, locked: boolean) => ({
|
||||
type: GROUP_JOIN_REQUEST,
|
||||
id,
|
||||
locked,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const joinGroupSuccess = (relationship: APIEntity) => ({
|
||||
type: GROUP_JOIN_SUCCESS,
|
||||
relationship,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const joinGroupFail = (error: AxiosError, locked: boolean) => ({
|
||||
type: GROUP_JOIN_FAIL,
|
||||
error,
|
||||
locked,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupRequest = (id: string) => ({
|
||||
type: GROUP_LEAVE_REQUEST,
|
||||
id,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupSuccess = (relationship: APIEntity) => ({
|
||||
type: GROUP_LEAVE_SUCCESS,
|
||||
relationship,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const leaveGroupFail = (error: AxiosError) => ({
|
||||
type: GROUP_LEAVE_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const groupDeleteStatus = (groupId: string, statusId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupDeleteStatusRequest(groupId, statusId));
|
||||
|
@ -869,9 +798,9 @@ const submitGroupEditor = (shouldReset?: boolean) => (dispatch: AppDispatch, get
|
|||
if (header) params.header = header;
|
||||
|
||||
if (groupId === null) {
|
||||
dispatch(createGroup(params, shouldReset));
|
||||
return dispatch(createGroup(params, shouldReset));
|
||||
} else {
|
||||
dispatch(updateGroup(groupId, params, shouldReset));
|
||||
return dispatch(updateGroup(groupId, params, shouldReset));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -895,12 +824,6 @@ export {
|
|||
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||
GROUP_JOIN_REQUEST,
|
||||
GROUP_JOIN_SUCCESS,
|
||||
GROUP_JOIN_FAIL,
|
||||
GROUP_LEAVE_REQUEST,
|
||||
GROUP_LEAVE_SUCCESS,
|
||||
GROUP_LEAVE_FAIL,
|
||||
GROUP_DELETE_STATUS_REQUEST,
|
||||
GROUP_DELETE_STATUS_SUCCESS,
|
||||
GROUP_DELETE_STATUS_FAIL,
|
||||
|
@ -973,14 +896,6 @@ export {
|
|||
fetchGroupRelationshipsRequest,
|
||||
fetchGroupRelationshipsSuccess,
|
||||
fetchGroupRelationshipsFail,
|
||||
joinGroup,
|
||||
leaveGroup,
|
||||
joinGroupRequest,
|
||||
joinGroupSuccess,
|
||||
joinGroupFail,
|
||||
leaveGroupRequest,
|
||||
leaveGroupSuccess,
|
||||
leaveGroupFail,
|
||||
groupDeleteStatus,
|
||||
groupDeleteStatusRequest,
|
||||
groupDeleteStatusSuccess,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { defineMessage } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchMe } from 'soapbox/actions/me';
|
||||
import messages from 'soapbox/locales/messages';
|
||||
import toast from 'soapbox/toast';
|
||||
import { isLoggedIn } from 'soapbox/utils/auth';
|
||||
|
||||
|
@ -21,9 +22,7 @@ type SettingOpts = {
|
|||
showAlert?: boolean
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
saveSuccess: { id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' },
|
||||
});
|
||||
const saveSuccessMessage = defineMessage({ id: 'settings.save.success', defaultMessage: 'Your preferences have been saved!' });
|
||||
|
||||
const defaultSettings = ImmutableMap({
|
||||
onboarded: false,
|
||||
|
@ -40,7 +39,7 @@ const defaultSettings = ImmutableMap({
|
|||
defaultPrivacy: 'public',
|
||||
defaultContentType: 'text/plain',
|
||||
themeMode: 'system',
|
||||
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
||||
locale: navigator.language || 'en',
|
||||
showExplanationBox: true,
|
||||
explanationBox: true,
|
||||
autoloadTimelines: true,
|
||||
|
@ -221,7 +220,7 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
|
|||
dispatch({ type: SETTING_SAVE });
|
||||
|
||||
if (opts?.showAlert) {
|
||||
toast.success(messages.saveSuccess);
|
||||
toast.success(saveSuccessMessage);
|
||||
}
|
||||
}).catch(error => {
|
||||
toast.showAlertForError(error);
|
||||
|
@ -231,6 +230,12 @@ const saveSettingsImmediate = (opts?: SettingOpts) =>
|
|||
const saveSettings = (opts?: SettingOpts) =>
|
||||
(dispatch: AppDispatch) => dispatch(saveSettingsImmediate(opts));
|
||||
|
||||
const getLocale = (state: RootState, fallback = 'en') => {
|
||||
const localeWithVariant = (getSettings(state).get('locale') as string).replace('_', '-');
|
||||
const locale = localeWithVariant.split('-')[0];
|
||||
return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(locale) ? locale : fallback;
|
||||
};
|
||||
|
||||
export {
|
||||
SETTING_CHANGE,
|
||||
SETTING_SAVE,
|
||||
|
@ -242,4 +247,5 @@ export {
|
|||
changeSetting,
|
||||
saveSettingsImmediate,
|
||||
saveSettings,
|
||||
getLocale,
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getLocale, getSettings } from 'soapbox/actions/settings';
|
||||
import messages from 'soapbox/locales/messages';
|
||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
@ -34,13 +34,6 @@ import type { APIEntity, Chat } from 'soapbox/types/entities';
|
|||
const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
|
||||
const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE';
|
||||
|
||||
const validLocale = (locale: string) => Object.keys(messages).includes(locale);
|
||||
|
||||
const getLocale = (state: RootState) => {
|
||||
const locale = getSettings(state).get('locale') as string;
|
||||
return validLocale(locale) ? locale : 'en';
|
||||
};
|
||||
|
||||
const updateFollowRelationships = (relationships: APIEntity) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const me = getState().me;
|
||||
|
|
|
@ -23,7 +23,12 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
|
|||
|
||||
export const getNextLink = (response: AxiosResponse) => {
|
||||
const nextLink = new LinkHeader(response.headers?.link);
|
||||
return nextLink.refs.find((ref) => ref.uri)?.uri;
|
||||
return nextLink.refs.find(link => link.rel === 'next')?.uri;
|
||||
};
|
||||
|
||||
export const getPrevLink = (response: AxiosResponse) => {
|
||||
const prevLink = new LinkHeader(response.headers?.link);
|
||||
return prevLink.refs.find(link => link.rel === 'prev')?.uri;
|
||||
};
|
||||
|
||||
export const baseClient = (...params: any[]) => {
|
||||
|
|
|
@ -14,10 +14,11 @@ import RelativeTimestamp from './relative-timestamp';
|
|||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||
|
||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||
import type { Account as AccountSchema } from 'soapbox/schemas';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IInstanceFavicon {
|
||||
account: AccountEntity
|
||||
account: AccountEntity | AccountSchema
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
@ -67,7 +68,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
|
|||
};
|
||||
|
||||
export interface IAccount {
|
||||
account: AccountEntity
|
||||
account: AccountEntity | AccountSchema
|
||||
action?: React.ReactElement
|
||||
actionAlignment?: 'center' | 'top'
|
||||
actionIcon?: string
|
||||
|
|
|
@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
|
||||
<li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
|
||||
<a
|
||||
href={item.href || item.to || '#'}
|
||||
role='button'
|
||||
|
|
|
@ -271,6 +271,10 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
|||
};
|
||||
}, [refs.floating.current]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Avatar, HStack, Icon, Stack, Text } from './ui';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
|
||||
|
||||
import GroupAvatar from './groups/group-avatar';
|
||||
import { HStack, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -17,7 +22,10 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stack className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'>
|
||||
<Stack
|
||||
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||
data-testid='group-card'
|
||||
>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
{group.header && (
|
||||
|
@ -30,37 +38,17 @@ const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
|||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<Avatar className='ring-2 ring-white dark:ring-primary-900' src={group.avatar} size={64} />
|
||||
<GroupAvatar group={group} size={64} withRing />
|
||||
</div>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={3} wrap>
|
||||
{group.relationship?.role === 'admin' ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/users.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.role.admin' defaultMessage='Admin' /></Text>
|
||||
</HStack>
|
||||
) : group.relationship?.role === 'moderator' && (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/gavel.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.role.moderator' defaultMessage='Moderator' /></Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{group.locked ? (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/lock.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.privacy.locked' defaultMessage='Private' /></Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon className='h-4 w-4' src={require('@tabler/icons/world.svg')} />
|
||||
<Text theme='inherit'><FormattedMessage id='group.privacy.public' defaultMessage='Public' /></Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupRelationship group={group} />
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
|
||||
import { Avatar } from '../ui';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
interface IGroupAvatar {
|
||||
group: Group
|
||||
size: number
|
||||
withRing?: boolean
|
||||
}
|
||||
|
||||
const GroupAvatar = (props: IGroupAvatar) => {
|
||||
const { group, size, withRing = false } = props;
|
||||
|
||||
const { normalizeRole } = useGroupRoles();
|
||||
|
||||
const isAdmin = normalizeRole(group.relationship?.role as any) === 'admin';
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={
|
||||
clsx('relative rounded-full', {
|
||||
'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isAdmin && withRing,
|
||||
'shadow-[0_0_0_2px_theme(colors.primary.600)]': isAdmin && !withRing,
|
||||
'shadow-[0_0_0_2px_theme(colors.white)]': !isAdmin && withRing,
|
||||
})
|
||||
}
|
||||
src={group.avatar}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupAvatar;
|
|
@ -84,7 +84,10 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
|
|||
{children}
|
||||
|
||||
{isSelected ? (
|
||||
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
|
||||
<Icon
|
||||
src={require('@tabler/icons/circle-check.svg')}
|
||||
className='h-4 w-4 text-primary-600 dark:fill-white dark:text-primary-800'
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -52,6 +52,8 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
|||
alwaysPrepend?: boolean
|
||||
/** Message to display when the list is loaded but empty. */
|
||||
emptyMessage?: React.ReactNode
|
||||
/** Should the empty message be displayed in a Card */
|
||||
emptyMessageCard?: boolean
|
||||
/** Scrollable content. */
|
||||
children: Iterable<React.ReactNode>
|
||||
/** Callback when the list is scrolled to the top. */
|
||||
|
@ -87,6 +89,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
children,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
emptyMessageCard = true,
|
||||
showLoading,
|
||||
onRefresh,
|
||||
onScroll,
|
||||
|
@ -158,13 +161,17 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
<div className='mt-2'>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<Card variant='rounded' size='lg'>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
emptyMessage
|
||||
)}
|
||||
</Card>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
{emptyMessageCard ? (
|
||||
<Card variant='rounded' size='lg'>
|
||||
{emptyMessage}
|
||||
</Card>
|
||||
) : emptyMessage}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,7 +8,7 @@ const themes = {
|
|||
tertiary:
|
||||
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
|
||||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 focus:text-gray-200 dark:focus:bg-danger-600 dark:focus:text-gray-100',
|
||||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:ring-danger-500',
|
||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
||||
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||
|
|
|
@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
|||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||
|
||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' |'className'>;
|
||||
type IColumnHeader = Pick<IColumn, 'label' | 'backHref' | 'className' | 'action'>;
|
||||
|
||||
/** Contains the column title with optional back button. */
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) => {
|
||||
const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className, action }) => {
|
||||
const history = useHistory();
|
||||
|
||||
const handleBackClick = () => {
|
||||
|
@ -29,6 +29,12 @@ const ColumnHeader: React.FC<IColumnHeader> = ({ label, backHref, className }) =
|
|||
return (
|
||||
<CardHeader className={className} onBackClick={handleBackClick}>
|
||||
<CardTitle title={label} />
|
||||
|
||||
{action && (
|
||||
<div className='flex grow justify-end'>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
@ -48,11 +54,12 @@ export interface IColumn {
|
|||
ref?: React.Ref<HTMLDivElement>
|
||||
/** Children to display in the column. */
|
||||
children?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
/** A backdrop for the main section of the UI. */
|
||||
const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedRef<HTMLDivElement>): JSX.Element => {
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className } = props;
|
||||
const { backHref, children, label, transparent = false, withHeader = true, className, action } = props;
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
return (
|
||||
|
@ -75,6 +82,7 @@ const Column: React.FC<IColumn> = React.forwardRef((props, ref: React.ForwardedR
|
|||
label={label}
|
||||
backHref={backHref}
|
||||
className={clsx({ 'px-4 pt-4 sm:p-0': transparent })}
|
||||
action={action}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ export {
|
|||
} from './menu/menu';
|
||||
export { default as Modal } from './modal/modal';
|
||||
export { default as PhoneInput } from './phone-input/phone-input';
|
||||
export { default as Popover } from './popover/popover';
|
||||
export { default as Portal } from './portal/portal';
|
||||
export { default as ProgressBar } from './progress-bar/progress-bar';
|
||||
export { default as RadioButton } from './radio-button/radio-button';
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
arrow,
|
||||
FloatingArrow,
|
||||
offset,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useTransitionStyles,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
interface IPopover {
|
||||
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover
|
||||
*
|
||||
* Similar to tooltip, but requires a click and is used for larger blocks
|
||||
* of information.
|
||||
*/
|
||||
const Popover: React.FC<IPopover> = (props) => {
|
||||
const { children, content } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { x, y, strategy, refs, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
offset(10),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const { isMounted, styles } = useTransitionStyles(context, {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
duration: {
|
||||
open: 200,
|
||||
close: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
className: 'cursor-help',
|
||||
})}
|
||||
|
||||
{(isMounted) && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
...styles,
|
||||
}}
|
||||
className='rounded-lg bg-white p-6 shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700'
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
|
||||
<FloatingArrow ref={arrowRef} context={context} className='fill-white dark:hidden' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popover;
|
|
@ -11,6 +11,7 @@ const spaces = {
|
|||
4: 'space-y-4',
|
||||
5: 'space-y-5',
|
||||
6: 'space-y-6',
|
||||
9: 'space-y-9',
|
||||
10: 'space-y-10',
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import { deleteEntities, entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions';
|
||||
import reducer, { State } from '../reducer';
|
||||
import { createListState } from '../utils';
|
||||
|
||||
import type { EntityCache } from '../types';
|
||||
|
||||
interface TestEntity {
|
||||
id: string
|
||||
msg: string
|
||||
}
|
||||
|
||||
test('import entities', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['1']!.msg).toBe('yolo');
|
||||
expect(Object.values(cache.lists).length).toBe(0);
|
||||
});
|
||||
|
||||
test('import entities into a list', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['2']!.msg).toBe('benis');
|
||||
expect(cache.lists.thingies?.ids.size).toBe(3);
|
||||
|
||||
// Now try adding an additional item.
|
||||
const entities2: TestEntity[] = [
|
||||
{ id: '4', msg: 'hehe' },
|
||||
];
|
||||
|
||||
const action2 = importEntities(entities2, 'TestEntity', 'thingies');
|
||||
const result2 = reducer(result, action2);
|
||||
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache2.store['4']!.msg).toBe('hehe');
|
||||
expect(cache2.lists.thingies?.ids.size).toBe(4);
|
||||
|
||||
// Finally, update an item.
|
||||
const entities3: TestEntity[] = [
|
||||
{ id: '2', msg: 'yolofam' },
|
||||
];
|
||||
|
||||
const action3 = importEntities(entities3, 'TestEntity', 'thingies');
|
||||
const result3 = reducer(result2, action3);
|
||||
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache3.store['2']!.msg).toBe('yolofam');
|
||||
expect(cache3.lists.thingies?.ids.size).toBe(4); // unchanged
|
||||
});
|
||||
|
||||
test('fetching updates the list state', () => {
|
||||
const action = entitiesFetchRequest('TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.fetching).toBe(true);
|
||||
});
|
||||
|
||||
test('failure adds the error to the state', () => {
|
||||
const error = new Error('whoopsie');
|
||||
|
||||
const action = entitiesFetchFail('TestEntity', 'thingies', error);
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.error).toBe(error);
|
||||
});
|
||||
|
||||
test('deleting items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
'': {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: createListState(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = deleteEntities(['3', '1'], 'TestEntity');
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } });
|
||||
expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']);
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
import type { Entity, EntityListState } from './types';
|
||||
|
||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
|
||||
const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
|
||||
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
|
||||
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
|
||||
|
@ -15,6 +16,19 @@ function importEntities(entities: Entity[], entityType: string, listKey?: string
|
|||
};
|
||||
}
|
||||
|
||||
interface DeleteEntitiesOpts {
|
||||
preserveLists?: boolean
|
||||
}
|
||||
|
||||
function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteEntitiesOpts = {}) {
|
||||
return {
|
||||
type: ENTITIES_DELETE,
|
||||
ids,
|
||||
entityType,
|
||||
opts,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchRequest(entityType: string, listKey?: string) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_REQUEST,
|
||||
|
@ -45,18 +59,23 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro
|
|||
/** Any action pertaining to entities. */
|
||||
type EntityAction =
|
||||
ReturnType<typeof importEntities>
|
||||
| ReturnType<typeof deleteEntities>
|
||||
| ReturnType<typeof entitiesFetchRequest>
|
||||
| ReturnType<typeof entitiesFetchSuccess>
|
||||
| ReturnType<typeof entitiesFetchFail>;
|
||||
|
||||
export {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
importEntities,
|
||||
deleteEntities,
|
||||
entitiesFetchRequest,
|
||||
entitiesFetchSuccess,
|
||||
entitiesFetchFail,
|
||||
EntityAction,
|
||||
};
|
||||
};
|
||||
|
||||
export type { DeleteEntitiesOpts };
|
|
@ -0,0 +1,7 @@
|
|||
export enum Entities {
|
||||
GROUPS = 'Groups',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
POPULAR_GROUPS = 'PopularGroups',
|
||||
SUGGESTED_GROUPS = 'SuggestedGroups',
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export { useEntities } from './useEntities';
|
||||
export { useEntity } from './useEntity';
|
||||
export { useEntity } from './useEntity';
|
||||
export { useEntityActions } from './useEntityActions';
|
|
@ -0,0 +1,6 @@
|
|||
import type { Entity } from '../types';
|
||||
import type z from 'zod';
|
||||
|
||||
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
|
||||
|
||||
export type { EntitySchema };
|
|
@ -1,30 +1,39 @@
|
|||
import { useEffect } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { getNextLink, getPrevLink } from 'soapbox/api';
|
||||
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
|
||||
import { filteredArray } from 'soapbox/schemas/utils';
|
||||
|
||||
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { Entity, EntityListState } from '../types';
|
||||
import type { EntitySchema } from './types';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/** Tells us where to find/store the entity in the cache. */
|
||||
type EntityPath = [
|
||||
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
||||
entityType: string,
|
||||
/** Name of a particular index of this entity type. You can use empty-string (`''`) if you don't need separate lists. */
|
||||
listKey: string,
|
||||
/**
|
||||
* Name of a particular index of this entity type.
|
||||
* Multiple params get combined into one string with a `:` separator.
|
||||
* You can use empty-string (`''`) if you don't need separate lists.
|
||||
*/
|
||||
...listKeys: string[],
|
||||
]
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntitiesOpts<TEntity> {
|
||||
/** A parser function that returns the desired type, or undefined if validation fails. */
|
||||
parser?: (entity: unknown) => TEntity | undefined
|
||||
interface UseEntitiesOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entities. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/**
|
||||
* Time (milliseconds) until this query becomes stale and should be refetched.
|
||||
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
|
||||
*/
|
||||
staleTime?: number
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** A hook for fetching and displaying API entities. */
|
||||
|
@ -38,44 +47,38 @@ function useEntities<TEntity extends Entity>(
|
|||
) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
|
||||
const [entityType, listKey] = path;
|
||||
const [entityType, ...listKeys] = path;
|
||||
const listKey = listKeys.join(':');
|
||||
|
||||
const defaultParser = (entity: unknown) => entity as TEntity;
|
||||
const parseEntity = opts.parser || defaultParser;
|
||||
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
||||
|
||||
const cache = useAppSelector(state => state.entities[entityType]);
|
||||
const list = cache?.lists[listKey];
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isFetching = useListState(path, 'fetching');
|
||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||
const isFetched = useListState(path, 'fetched');
|
||||
const isError = !!useListState(path, 'error');
|
||||
|
||||
const entityIds = list?.ids;
|
||||
|
||||
const entities: readonly TEntity[] = entityIds ? (
|
||||
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
|
||||
const entity = parseEntity(cache?.store[id] as unknown);
|
||||
if (entity) {
|
||||
result.push(entity);
|
||||
}
|
||||
return result;
|
||||
}, [])
|
||||
) : [];
|
||||
|
||||
const isFetching = Boolean(list?.state.fetching);
|
||||
const isLoading = isFetching && entities.length === 0;
|
||||
const hasNextPage = Boolean(list?.state.next);
|
||||
const hasPreviousPage = Boolean(list?.state.prev);
|
||||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(url: string): Promise<void> => {
|
||||
// Get `isFetching` state from the store again to prevent race conditions.
|
||||
const isFetching = dispatch((_, getState: () => RootState) => Boolean(getState().entities[entityType]?.lists[listKey]?.state.fetching));
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
||||
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||
try {
|
||||
const response = await api.get(url);
|
||||
dispatch(entitiesFetchSuccess(response.data, entityType, listKey, {
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, {
|
||||
next: getNextLink(response),
|
||||
prev: getPrevLink(response),
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
}));
|
||||
|
@ -91,42 +94,79 @@ function useEntities<TEntity extends Entity>(
|
|||
};
|
||||
|
||||
const fetchNextPage = async(): Promise<void> => {
|
||||
const next = list?.state.next;
|
||||
|
||||
if (next) {
|
||||
await fetchPage(next);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreviousPage = async(): Promise<void> => {
|
||||
const prev = list?.state.prev;
|
||||
|
||||
if (prev) {
|
||||
await fetchPage(prev);
|
||||
}
|
||||
};
|
||||
|
||||
const staleTime = opts.staleTime ?? 60000;
|
||||
const lastFetchedAt = list?.state.lastFetchedAt;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
|
||||
if (isEnabled && !isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
|
||||
fetchEntities();
|
||||
}
|
||||
}, [endpoint]);
|
||||
}, [endpoint, isEnabled]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
fetchEntities,
|
||||
isFetching,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage: !!next,
|
||||
hasPreviousPage: !!prev,
|
||||
isError,
|
||||
isFetched,
|
||||
isFetching,
|
||||
isLoading: isFetching && entities.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get cache at path from Redux. */
|
||||
const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]];
|
||||
|
||||
/** Get list at path from Redux. */
|
||||
const selectList = (state: RootState, path: EntityPath) => {
|
||||
const [, ...listKeys] = path;
|
||||
const listKey = listKeys.join(':');
|
||||
|
||||
return selectCache(state, path)?.lists[listKey];
|
||||
};
|
||||
|
||||
/** Select a particular item from a list state. */
|
||||
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntityPath, key: K) {
|
||||
const listState = selectList(state, path)?.state;
|
||||
return listState ? listState[key] : undefined;
|
||||
}
|
||||
|
||||
/** Hook to get a particular item from a list state. */
|
||||
function useListState<K extends keyof EntityListState>(path: EntityPath, key: K) {
|
||||
return useAppSelector(state => selectListState(state, path, key));
|
||||
}
|
||||
|
||||
/** Get list of entities from Redux. */
|
||||
function selectEntities<TEntity extends Entity>(state: RootState, path: EntityPath): readonly TEntity[] {
|
||||
const cache = selectCache(state, path);
|
||||
const list = selectList(state, path);
|
||||
|
||||
const entityIds = list?.ids;
|
||||
|
||||
return entityIds ? (
|
||||
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
|
||||
const entity = cache?.store[id];
|
||||
if (entity) {
|
||||
result.push(entity as TEntity);
|
||||
}
|
||||
return result;
|
||||
}, [])
|
||||
) : [];
|
||||
}
|
||||
|
||||
export {
|
||||
useEntities,
|
||||
};
|
|
@ -1,17 +1,19 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { EntitySchema } from './types';
|
||||
|
||||
type EntityPath = [entityType: string, entityId: string]
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntityOpts<TEntity> {
|
||||
/** A parser function that returns the desired type, or undefined if validation fails. */
|
||||
parser?: (entity: unknown) => TEntity | undefined
|
||||
interface UseEntityOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entity. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
}
|
||||
|
@ -26,10 +28,10 @@ function useEntity<TEntity extends Entity>(
|
|||
|
||||
const [entityType, entityId] = path;
|
||||
|
||||
const defaultParser = (entity: unknown) => entity as TEntity;
|
||||
const parseEntity = opts.parser || defaultParser;
|
||||
const defaultSchema = z.custom<TEntity>();
|
||||
const schema = opts.schema || defaultSchema;
|
||||
|
||||
const entity = useAppSelector(state => parseEntity(state.entities[entityType]?.store[entityId]));
|
||||
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const isLoading = isFetching && !entity;
|
||||
|
@ -37,7 +39,8 @@ function useEntity<TEntity extends Entity>(
|
|||
const fetchEntity = () => {
|
||||
setIsFetching(true);
|
||||
api.get(endpoint).then(({ data }) => {
|
||||
dispatch(importEntities([data], entityType));
|
||||
const entity = schema.parse(data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
setIsFetching(false);
|
||||
}).catch(() => {
|
||||
setIsFetching(false);
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { useApi, useAppDispatch, useGetState } from 'soapbox/hooks';
|
||||
|
||||
import { deleteEntities, importEntities } from '../actions';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { EntitySchema } from './types';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
type EntityPath = [entityType: string, listKey?: string]
|
||||
|
||||
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
}
|
||||
|
||||
interface CreateEntityResult<TEntity extends Entity = Entity> {
|
||||
response: AxiosResponse
|
||||
entity: TEntity
|
||||
}
|
||||
|
||||
interface DeleteEntityResult {
|
||||
response: AxiosResponse
|
||||
}
|
||||
|
||||
interface EntityActionEndpoints {
|
||||
post?: string
|
||||
delete?: string
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
||||
path: EntityPath,
|
||||
endpoints: EntityActionEndpoints,
|
||||
opts: UseEntityActionsOpts<TEntity> = {},
|
||||
) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
const [entityType, listKey] = path;
|
||||
|
||||
function createEntity(params: P): Promise<CreateEntityResult<TEntity>> {
|
||||
if (!endpoints.post) return Promise.reject(endpoints);
|
||||
|
||||
return api.post(endpoints.post, params).then((response) => {
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entity = schema.parse(response.data);
|
||||
|
||||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey));
|
||||
|
||||
return {
|
||||
response,
|
||||
entity,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deleteEntity(entityId: string): Promise<DeleteEntityResult> {
|
||||
if (!endpoints.delete) return Promise.reject(endpoints);
|
||||
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||
const entity = getState().entities[entityType]?.store[entityId];
|
||||
// Optimistically delete the entity from the _store_ but keep the lists in tact.
|
||||
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
|
||||
|
||||
return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => {
|
||||
// Success - finish deleting entity from the state.
|
||||
dispatch(deleteEntities([entityId], entityType));
|
||||
|
||||
return {
|
||||
response,
|
||||
};
|
||||
}).catch((e) => {
|
||||
if (entity) {
|
||||
// If the API failed, reimport the entity.
|
||||
dispatch(importEntities([entity], entityType));
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createEntity: createEntity,
|
||||
deleteEntity: endpoints.delete ? deleteEntity : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useEntityActions };
|
|
@ -2,6 +2,7 @@ import produce, { enableMapSet } from 'immer';
|
|||
|
||||
import {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
|
@ -9,6 +10,7 @@ import {
|
|||
} from './actions';
|
||||
import { createCache, createList, updateStore, updateList } from './utils';
|
||||
|
||||
import type { DeleteEntitiesOpts } from './actions';
|
||||
import type { Entity, EntityCache, EntityListState } from './types';
|
||||
|
||||
enableMapSet();
|
||||
|
@ -43,11 +45,35 @@ const importEntities = (
|
|||
});
|
||||
};
|
||||
|
||||
const deleteEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
ids: Iterable<string>,
|
||||
opts: DeleteEntitiesOpts,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
||||
for (const id of ids) {
|
||||
delete cache.store[id];
|
||||
|
||||
if (!opts?.preserveLists) {
|
||||
for (const list of Object.values(cache.lists)) {
|
||||
list?.ids.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const setFetching = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
listKey: string | undefined,
|
||||
isFetching: boolean,
|
||||
error?: any,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
@ -55,6 +81,7 @@ const setFetching = (
|
|||
if (typeof listKey === 'string') {
|
||||
const list = cache.lists[listKey] ?? createList();
|
||||
list.state.fetching = isFetching;
|
||||
list.state.error = error;
|
||||
cache.lists[listKey] = list;
|
||||
}
|
||||
|
||||
|
@ -67,15 +94,18 @@ function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
|||
switch (action.type) {
|
||||
case ENTITIES_IMPORT:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey);
|
||||
case ENTITIES_DELETE:
|
||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||
case ENTITIES_FETCH_SUCCESS:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState);
|
||||
case ENTITIES_FETCH_REQUEST:
|
||||
return setFetching(state, action.entityType, action.listKey, true);
|
||||
case ENTITIES_FETCH_FAIL:
|
||||
return setFetching(state, action.entityType, action.listKey, false);
|
||||
return setFetching(state, action.entityType, action.listKey, false, action.error);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer;
|
||||
export default reducer;
|
||||
export type { State };
|
|
@ -5,8 +5,8 @@ interface Entity {
|
|||
}
|
||||
|
||||
/** Store of entities by ID. */
|
||||
interface EntityStore {
|
||||
[id: string]: Entity | undefined
|
||||
interface EntityStore<TEntity extends Entity = Entity> {
|
||||
[id: string]: TEntity | undefined
|
||||
}
|
||||
|
||||
/** List of entity IDs and fetch state. */
|
||||
|
@ -25,6 +25,8 @@ interface EntityListState {
|
|||
prev: string | undefined
|
||||
/** Error returned from the API, if any. */
|
||||
error: any
|
||||
/** Whether data has already been fetched */
|
||||
fetched: boolean
|
||||
/** Whether data for this list is currently being fetched. */
|
||||
fetching: boolean
|
||||
/** Date of the last API fetch for this list. */
|
||||
|
@ -32,9 +34,9 @@ interface EntityListState {
|
|||
}
|
||||
|
||||
/** Cache data pertaining to a paritcular entity type.. */
|
||||
interface EntityCache {
|
||||
interface EntityCache<TEntity extends Entity = Entity> {
|
||||
/** Map of entities of this type. */
|
||||
store: EntityStore
|
||||
store: EntityStore<TEntity>
|
||||
/** Lists of entity IDs for a particular purpose. */
|
||||
lists: {
|
||||
[listKey: string]: EntityList | undefined
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Entity, EntityStore, EntityList, EntityCache } from './types';
|
||||
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types';
|
||||
|
||||
/** Insert the entities into the store. */
|
||||
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
||||
|
@ -26,13 +26,17 @@ const createCache = (): EntityCache => ({
|
|||
/** Create an empty entity list. */
|
||||
const createList = (): EntityList => ({
|
||||
ids: new Set(),
|
||||
state: {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
fetching: false,
|
||||
error: null,
|
||||
lastFetchedAt: undefined,
|
||||
},
|
||||
state: createListState(),
|
||||
});
|
||||
|
||||
/** Create an empty entity list state. */
|
||||
const createListState = (): EntityListState => ({
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
error: null,
|
||||
fetched: false,
|
||||
fetching: false,
|
||||
lastFetchedAt: undefined,
|
||||
});
|
||||
|
||||
export {
|
||||
|
@ -40,4 +44,5 @@ export {
|
|||
updateList,
|
||||
createCache,
|
||||
createList,
|
||||
createListState,
|
||||
};
|
|
@ -35,27 +35,29 @@ const AccountAuthorize: React.FC<IAccountAuthorize> = ({ id }) => {
|
|||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
<HStack space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
icon={require('@tabler/icons/check.svg')}
|
||||
onClick={onAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
icon={require('@tabler/icons/x.svg')}
|
||||
onClick={onReject}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div className='p-2.5'>
|
||||
<Account
|
||||
account={account}
|
||||
action={
|
||||
<HStack className='ml-1' space={2}>
|
||||
<Button
|
||||
theme='secondary'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.authorize)}
|
||||
icon={require('@tabler/icons/check.svg')}
|
||||
onClick={onAuthorize}
|
||||
/>
|
||||
<Button
|
||||
theme='danger'
|
||||
size='sm'
|
||||
text={intl.formatMessage(messages.reject)}
|
||||
icon={require('@tabler/icons/x.svg')}
|
||||
onClick={onReject}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupActionButton from '../group-action-button';
|
||||
|
@ -11,14 +11,14 @@ let group: Group;
|
|||
describe('<GroupActionButton />', () => {
|
||||
describe('with no group relationship', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
relationship: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
group = { ...group, locked: true };
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
|
@ -30,7 +30,7 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
group = { ...group, locked: false };
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
|
@ -43,8 +43,8 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('with no group relationship member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
member: null,
|
||||
}),
|
||||
});
|
||||
|
@ -52,7 +52,7 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
group = { ...group, locked: true };
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
|
@ -64,7 +64,7 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
group = { ...group, locked: false };
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
|
@ -77,8 +77,8 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('when the user has requested to join', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: true,
|
||||
member: true,
|
||||
}),
|
||||
|
@ -94,8 +94,8 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('when the user is an Admin', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'admin',
|
||||
|
@ -112,8 +112,8 @@ describe('<GroupActionButton />', () => {
|
|||
|
||||
describe('when the user is just a member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'user',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupMemberCount from '../group-member-count';
|
||||
|
@ -9,24 +9,10 @@ import GroupMemberCount from '../group-member-count';
|
|||
let group: Group;
|
||||
|
||||
describe('<GroupMemberCount />', () => {
|
||||
describe('without support for "members_count"', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with support for "members_count"', () => {
|
||||
describe('with 1 member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
members_count: 1,
|
||||
});
|
||||
});
|
||||
|
@ -40,7 +26,7 @@ describe('<GroupMemberCount />', () => {
|
|||
|
||||
describe('with 2 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
members_count: 2,
|
||||
});
|
||||
});
|
||||
|
@ -54,7 +40,7 @@ describe('<GroupMemberCount />', () => {
|
|||
|
||||
describe('with 1000 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
members_count: 1000,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupPrivacy from '../group-privacy';
|
||||
|
@ -11,7 +11,7 @@ let group: Group;
|
|||
describe('<GroupPrivacy />', () => {
|
||||
describe('with a Private group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
|
@ -25,7 +25,7 @@ describe('<GroupPrivacy />', () => {
|
|||
|
||||
describe('with a Public group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
group = buildGroup({
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { useCancelMembershipRequest, useJoinGroup, useLeaveGroup } from 'soapbox/queries/groups';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupActionButton {
|
||||
|
@ -21,25 +21,37 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
|||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const isNonMember = !group.relationship || !group.relationship.member;
|
||||
const isRequested = group.relationship?.requested;
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
const joinGroup = useJoinGroup();
|
||||
const leaveGroup = useLeaveGroup();
|
||||
const cancelRequest = useCancelMembershipRequest();
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
const isRequested = group.relationship?.requested;
|
||||
const isNonMember = !group.relationship?.member && !isRequested;
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
const isBlocked = group.relationship?.blocked_by;
|
||||
|
||||
const onJoinGroup = () => joinGroup.mutate(group);
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
onConfirm: () => leaveGroup.mutate(group),
|
||||
}));
|
||||
|
||||
const onCancelRequest = () => cancelRequest.mutate(group);
|
||||
|
||||
if (isBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNonMember) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
disabled={joinGroup.isLoading}
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
|
@ -52,7 +64,8 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
|||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
onClick={onCancelRequest}
|
||||
disabled={cancelRequest.isLoading}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel Request' />
|
||||
</Button>
|
||||
|
@ -74,6 +87,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
|||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
disabled={leaveGroup.isLoading}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave Group' />
|
||||
</Button>
|
||||
|
|
|
@ -3,8 +3,9 @@ import React from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import GroupAvatar from 'soapbox/components/groups/group-avatar';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||
import { isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
|
@ -109,30 +110,34 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
|
|||
|
||||
<div className='absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2'>
|
||||
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
|
||||
<Avatar
|
||||
className='ring-[3px] ring-white dark:ring-primary-900'
|
||||
src={group.avatar}
|
||||
<GroupAvatar
|
||||
group={group}
|
||||
size={80}
|
||||
withRing
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack alignItems='center' space={3} className='mt-10 py-4'>
|
||||
<Stack alignItems='center' space={3} className='mx-auto mt-10 w-5/6 py-4'>
|
||||
<Text
|
||||
size='xl'
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<Stack space={1}>
|
||||
<Stack space={1} alignItems='center'>
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupRelationship group={group} />
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
|
||||
<Text theme='muted' dangerouslySetInnerHTML={{ __html: group.note_emojified }} />
|
||||
<Text
|
||||
theme='muted'
|
||||
align='center'
|
||||
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<GroupActionButton group={group} />
|
||||
|
|
|
@ -10,10 +10,6 @@ interface IGroupMemberCount {
|
|||
}
|
||||
|
||||
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
|
||||
if (typeof group.members_count === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
|
|
|
@ -0,0 +1,196 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useMemo } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Account from 'soapbox/components/account';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember';
|
||||
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||
import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'You have successfully blocked @{name} from the group' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Ban from group' },
|
||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
});
|
||||
|
||||
interface IGroupMemberListItem {
|
||||
member: GroupMember
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||
const { member, group } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const intl = useIntl();
|
||||
|
||||
const { normalizeRole } = useGroupRoles();
|
||||
const blockGroupMember = useBlockGroupMember(group, member);
|
||||
|
||||
const account = useAccount(member.account.id) as AccountEntity;
|
||||
|
||||
// Current user role
|
||||
const isCurrentUserAdmin = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.ADMIN;
|
||||
const isCurrentUserModerator = normalizeRole(group.relationship?.role as any) === BaseGroupRoles.MODERATOR;
|
||||
|
||||
// Member role
|
||||
const isMemberAdmin = normalizeRole(member.role as any) === BaseGroupRoles.ADMIN;
|
||||
const isMemberModerator = normalizeRole(member.role as any) === BaseGroupRoles.MODERATOR;
|
||||
const isMemberUser = normalizeRole(member.role as any) === BaseGroupRoles.USER;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickConfirm),
|
||||
onConfirm: () => dispatch(groupKick(group.id, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => {
|
||||
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct }));
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(group.id, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => onPromote('admin', true);
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', group.relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(group.id, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
const menu: IMenu = useMemo(() => {
|
||||
const items: IMenu = [];
|
||||
|
||||
if (!group || !account || !group.relationship?.role) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (
|
||||
(isCurrentUserAdmin || isCurrentUserModerator) &&
|
||||
(isMemberModerator || isMemberUser) &&
|
||||
member.role !== group.relationship.role
|
||||
) {
|
||||
if (features.groupsKick) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
action: handleKickFromGroup,
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
action: handleBlockFromGroup,
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCurrentUserAdmin && !isMemberAdmin && account.acct === account.username) {
|
||||
items.push(null);
|
||||
|
||||
if (isMemberModerator) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupAdmin,
|
||||
});
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||
action: handleDemote,
|
||||
});
|
||||
} else if (isMemberUser) {
|
||||
items.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupMod,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [group, account]);
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<div className='w-full'>
|
||||
<Account account={member.account} withRelationship={false} />
|
||||
</div>
|
||||
|
||||
<HStack alignItems='center' space={2}>
|
||||
{(isMemberAdmin || isMemberModerator) ? (
|
||||
<span
|
||||
className={
|
||||
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
|
||||
'bg-primary-200 text-primary-500': isMemberAdmin,
|
||||
'bg-gray-200 text-gray-900': isMemberModerator,
|
||||
})
|
||||
}
|
||||
>
|
||||
{member.role}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu items={menu} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMemberListItem;
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { HStack, Icon, Popover, Stack, Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupPolicy {
|
||||
|
@ -9,24 +9,59 @@ interface IGroupPolicy {
|
|||
}
|
||||
|
||||
const GroupPrivacy = ({ group }: IGroupPolicy) => (
|
||||
<HStack space={1} alignItems='center' data-testid='group-privacy'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
group.locked
|
||||
? require('@tabler/icons/lock.svg')
|
||||
: require('@tabler/icons/world.svg')
|
||||
}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<Stack space={4} alignItems='center' className='w-72'>
|
||||
<div className='rounded-full bg-gray-200 p-3 dark:bg-gray-800'>
|
||||
<Icon
|
||||
src={
|
||||
group.locked
|
||||
? require('@tabler/icons/lock.svg')
|
||||
: require('@tabler/icons/world.svg')
|
||||
}
|
||||
className='h-6 w-6 text-gray-600 dark:text-gray-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Stack space={1} alignItems='center'>
|
||||
<Text size='lg' weight='bold' align='center'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked.full' defaultMessage='Private Group' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public.full' defaultMessage='Public Group' />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked.info' defaultMessage='Discoverable. Users can join after their request is approved.' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public.info' defaultMessage='Discoverable. Anyone can join.' />
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<HStack space={1} alignItems='center' data-testid='group-privacy'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
group.locked
|
||||
? require('@tabler/icons/lock.svg')
|
||||
: require('@tabler/icons/world.svg')
|
||||
}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
export default GroupPrivacy;
|
|
@ -1,283 +1,59 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Account from 'soapbox/components/account';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
import { useGroupMembers } from 'soapbox/hooks/api/useGroupMembers';
|
||||
import { useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||
import { useGroup } from 'soapbox/queries/groups';
|
||||
|
||||
import PlaceholderAccount from '../placeholder/components/placeholder-account';
|
||||
|
||||
import type { Menu as MenuType } from 'soapbox/components/dropdown-menu';
|
||||
import type { GroupRole, List } from 'soapbox/reducers/group-memberships';
|
||||
import type { GroupRelationship } from 'soapbox/types/entities';
|
||||
import GroupMemberListItem from './components/group-member-list-item';
|
||||
|
||||
type RouteParams = { id: string };
|
||||
|
||||
const messages = defineMessages({
|
||||
adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' },
|
||||
moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' },
|
||||
userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' },
|
||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||
groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' },
|
||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
|
||||
kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
|
||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||
promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' },
|
||||
promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' },
|
||||
kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' },
|
||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
||||
promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' },
|
||||
promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' },
|
||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||
});
|
||||
|
||||
interface IGroupMember {
|
||||
accountId: string
|
||||
accountRole: GroupRole
|
||||
groupId: string
|
||||
relationship?: GroupRelationship
|
||||
}
|
||||
|
||||
const GroupMember: React.FC<IGroupMember> = ({ accountId, accountRole, groupId, relationship }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleKickFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.kickConfirm),
|
||||
onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.kicked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlockFromGroup = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() =>
|
||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => {
|
||||
if (warning) {
|
||||
return dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }),
|
||||
confirm: intl.formatMessage(messages.promoteConfirm),
|
||||
onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() =>
|
||||
toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToGroupAdmin = () => {
|
||||
onPromote('admin', true);
|
||||
};
|
||||
|
||||
const handlePromoteToGroupMod = () => {
|
||||
onPromote('moderator', relationship!.role === 'moderator');
|
||||
};
|
||||
|
||||
const handleDemote = () => {
|
||||
dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() =>
|
||||
toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })),
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const menu: MenuType = [];
|
||||
|
||||
if (!relationship || !relationship.role) return menu;
|
||||
|
||||
if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||
icon: require('@tabler/icons/user-minus.svg'),
|
||||
action: handleKickFromGroup,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
action: handleBlockFromGroup,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) {
|
||||
menu.push(null);
|
||||
switch (accountRole) {
|
||||
case 'moderator':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupAdmin,
|
||||
});
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModDemote, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
||||
action: handleDemote,
|
||||
});
|
||||
break;
|
||||
case 'user':
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }),
|
||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
||||
action: handlePromoteToGroupMod,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return menu;
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center' justifyContent='between' className='p-2.5'>
|
||||
<div className='w-full'>
|
||||
<Account account={account} withRelationship={false} />
|
||||
</div>
|
||||
{menu.length > 0 && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='h-4 w-4'
|
||||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
} else {
|
||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
|
||||
|
||||
return (
|
||||
<Comp key={idx} {...itemProps} className='group'>
|
||||
<HStack space={3} alignItems='center'>
|
||||
{menuItem.icon && (
|
||||
<SvgIcon src={menuItem.icon} className='h-5 w-5 flex-none text-gray-400 group-hover:text-gray-500' />
|
||||
)}
|
||||
|
||||
<div className='truncate'>{menuItem.text}</div>
|
||||
</HStack>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
import type { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupMembers {
|
||||
params: RouteParams
|
||||
params: { id: string }
|
||||
}
|
||||
|
||||
const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { roles: { admin, moderator, user } } = useGroupRoles();
|
||||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId));
|
||||
const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId));
|
||||
const users = useAppSelector((state) => state.group_memberships.user.get(groupId));
|
||||
const { group, isFetching: isFetchingGroup } = useGroup(groupId);
|
||||
const { groupMembers: admins, isFetching: isFetchingAdmins } = useGroupMembers(groupId, admin);
|
||||
const { groupMembers: moderators, isFetching: isFetchingModerators } = useGroupMembers(groupId, moderator);
|
||||
const { groupMembers: users, isFetching: isFetchingUsers, fetchNextPage, hasNextPage } = useGroupMembers(groupId, user);
|
||||
|
||||
const handleLoadMore = (role: 'admin' | 'moderator' | 'user') => {
|
||||
dispatch(expandGroupMemberships(groupId, role));
|
||||
};
|
||||
const isLoading = isFetchingGroup || isFetchingAdmins || isFetchingModerators || isFetchingUsers;
|
||||
|
||||
const handleLoadMoreAdmins = useCallback(debounce(() => {
|
||||
handleLoadMore('admin');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreModerators = useCallback(debounce(() => {
|
||||
handleLoadMore('moderator');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const handleLoadMoreUsers = useCallback(debounce(() => {
|
||||
handleLoadMore('user');
|
||||
}, 300, { leading: true }), []);
|
||||
|
||||
const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => {
|
||||
if (!memberships?.isLoading && !memberships?.items.count()) return;
|
||||
|
||||
return (
|
||||
<React.Fragment key={role}>
|
||||
<CardHeader className='mt-4'>
|
||||
<CardTitle title={intl.formatMessage(messages[`${role}Subheading`])} />
|
||||
</CardHeader>
|
||||
<ScrollableList
|
||||
scrollKey={`group_${role}s-${groupId}`}
|
||||
hasMore={!!memberships?.next}
|
||||
onLoadMore={handler}
|
||||
isLoading={memberships?.isLoading}
|
||||
showLoading={memberships?.isLoading && !memberships?.items?.count()}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
>
|
||||
{memberships?.items?.map(accountId => (
|
||||
<GroupMember
|
||||
key={accountId}
|
||||
accountId={accountId}
|
||||
accountRole={role}
|
||||
groupId={groupId}
|
||||
relationship={relationship}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGroup(groupId));
|
||||
|
||||
dispatch(fetchGroupMemberships(groupId, 'admin'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'moderator'));
|
||||
dispatch(fetchGroupMemberships(groupId, 'user'));
|
||||
}, [groupId]);
|
||||
const members = useMemo(() => [
|
||||
...admins,
|
||||
...moderators,
|
||||
...users,
|
||||
], [admins, moderators, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMemberships(admins, 'admin', handleLoadMoreAdmins)}
|
||||
{renderMemberships(moderators, 'moderator', handleLoadMoreModerators)}
|
||||
{renderMemberships(users, 'user', handleLoadMoreUsers)}
|
||||
<ScrollableList
|
||||
scrollKey='group-members'
|
||||
hasMore={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
isLoading={isLoading || !group}
|
||||
showLoading={!group || isLoading && members.length === 0}
|
||||
placeholderComponent={PlaceholderAccount}
|
||||
placeholderCount={3}
|
||||
className='divide-y divide-solid divide-gray-300'
|
||||
itemClassName='py-3 last:pb-0'
|
||||
>
|
||||
{members.map((member) => (
|
||||
<GroupMemberListItem
|
||||
group={group as Group}
|
||||
member={member}
|
||||
key={member.account.id}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,9 +5,9 @@ import { Link } from 'react-router-dom';
|
|||
import { groupCompose } from 'soapbox/actions/compose';
|
||||
import { connectGroupStream } from 'soapbox/actions/streaming';
|
||||
import { expandGroupTimeline } from 'soapbox/actions/timelines';
|
||||
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
|
||||
import { Avatar, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import ComposeForm from 'soapbox/features/compose/components/compose-form';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import Timeline from '../ui/components/timeline';
|
||||
|
||||
|
@ -23,7 +23,9 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
|
||||
const groupId = props.params.id;
|
||||
|
||||
const relationship = useAppSelector((state) => state.group_relationships.get(groupId));
|
||||
const { group } = useGroup(groupId);
|
||||
|
||||
const canComposeGroupStatus = !!account && group?.relationship?.member;
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandGroupTimeline(groupId, { maxId }));
|
||||
|
@ -41,9 +43,13 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
};
|
||||
}, [groupId]);
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack space={2}>
|
||||
{!!account && relationship?.member && (
|
||||
{canComposeGroupStatus && (
|
||||
<div className='border-b border-solid border-gray-200 px-2 py-4 dark:border-gray-800'>
|
||||
<HStack alignItems='start' space={4}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
|
@ -59,11 +65,26 @@ const GroupTimeline: React.FC<IGroupTimeline> = (props) => {
|
|||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Timeline
|
||||
scrollKey='group_timeline'
|
||||
timelineId={`group:${groupId}`}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />}
|
||||
emptyMessage={
|
||||
<Stack space={4} className='py-6' justifyContent='center' alignItems='center'>
|
||||
<div className='rounded-full bg-gray-200 p-4'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/message-2.svg')}
|
||||
className='h-6 w-6 text-gray-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='empty_column.group' defaultMessage='There are no posts in this group yet.' />
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
emptyMessageCard={false}
|
||||
divideType='border'
|
||||
showGroup={false}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import PendingRequests from '../pending-requests';
|
||||
|
||||
const userId = '1';
|
||||
const store: any = {
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
software: 'TRUTHSOCIAL',
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = () => (
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||
<PendingRequests />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
undefined,
|
||||
store,
|
||||
)
|
||||
);
|
||||
|
||||
describe('<PendingRequests />', () => {
|
||||
describe('without pending group requests', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups?pending=true').reply(200, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the blankslate', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pending-requests-blankslate')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('group-card')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with pending group requests', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
|
||||
mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [
|
||||
normalizeGroupRelationship({
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the groups', async () => {
|
||||
renderApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('group-card')).toHaveLength(1);
|
||||
expect(screen.queryAllByTestId('pending-requests-blankslate')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import PendingGroupsRow from '../pending-groups-row';
|
||||
|
||||
const userId = '1';
|
||||
let store: any = {
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = (store: any) => (
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||
<PendingGroupsRow />
|
||||
</VirtuosoMockContext.Provider>,
|
||||
undefined,
|
||||
store,
|
||||
)
|
||||
);
|
||||
|
||||
describe('<PendingGroupRows />', () => {
|
||||
describe('without the feature', () => {
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
...store,
|
||||
instance: normalizeInstance({
|
||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
it('should not render', () => {
|
||||
renderApp(store);
|
||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the feature', () => {
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
...store,
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
software: 'TRUTHSOCIAL',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('without pending group requests', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups?pending=true').reply(200, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render', () => {
|
||||
renderApp(store);
|
||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with pending group requests', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
|
||||
mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [
|
||||
normalizeGroupRelationship({
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the row', async () => {
|
||||
renderApp(store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,9 +2,11 @@ import React, { forwardRef } from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar, Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupAvatar from 'soapbox/components/groups/group-avatar';
|
||||
import { Button, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||
import { useJoinGroup } from 'soapbox/queries/groups';
|
||||
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroup {
|
||||
|
@ -12,9 +14,13 @@ interface IGroup {
|
|||
width?: number
|
||||
}
|
||||
|
||||
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const { group, width = 'auto' } = props;
|
||||
|
||||
const joinGroup = useJoinGroup();
|
||||
|
||||
const onJoinGroup = () => joinGroup.mutate(group);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
|
@ -38,9 +44,8 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
|
|||
)}
|
||||
|
||||
<Stack justifyContent='end' className='z-10 p-4 text-white' space={3}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white'
|
||||
src={group.avatar}
|
||||
<GroupAvatar
|
||||
group={group}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
|
@ -69,6 +74,8 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
|
|||
<Button
|
||||
theme='primary'
|
||||
block
|
||||
onClick={onJoinGroup}
|
||||
disabled={joinGroup.isLoading}
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
|
@ -78,4 +85,4 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
|
|||
);
|
||||
});
|
||||
|
||||
export default Group;
|
||||
export default GroupGridItem;
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import GroupAvatar from 'soapbox/components/groups/group-avatar';
|
||||
import { Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useJoinGroup } from 'soapbox/queries/groups';
|
||||
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
interface IGroup {
|
||||
group: GroupEntity
|
||||
withJoinAction?: boolean
|
||||
}
|
||||
|
||||
const GroupListItem = (props: IGroup) => {
|
||||
const { group, withJoinAction = true } = props;
|
||||
|
||||
const joinGroup = useJoinGroup();
|
||||
|
||||
const onJoinGroup = () => joinGroup.mutate(group);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
>
|
||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<GroupAvatar
|
||||
group={group}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4.5 w-4.5'
|
||||
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{typeof group.members_count !== 'undefined' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</Link>
|
||||
|
||||
{withJoinAction && (
|
||||
<Button theme='primary' onClick={onJoinGroup} disabled={joinGroup.isLoading}>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupListItem;
|
|
@ -1,52 +1,78 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||
import { usePopularGroups } from 'soapbox/queries/groups';
|
||||
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
|
||||
|
||||
import Group from './group';
|
||||
import GroupGridItem from './group-grid-item';
|
||||
|
||||
const PopularGroups = () => {
|
||||
const { groups, isFetching } = usePopularGroups();
|
||||
const { groups, isFetching, isFetched, isError } = usePopularGroups();
|
||||
const isEmpty = (isFetched && groups.length === 0) || isError;
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Popular Groups
|
||||
</Text>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.popular.title'
|
||||
defaultMessage='Popular Groups'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
<Link to='/groups/popular'>
|
||||
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.popular.show_more'
|
||||
defaultMessage='Show More'
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
</HStack>
|
||||
|
||||
{isEmpty ? (
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.popular.empty'
|
||||
defaultMessage='Unable to fetch popular groups at this time. Please check back later.'
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupGridItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import Search from '../search';
|
||||
|
||||
|
@ -35,7 +36,7 @@ describe('<Search />', () => {
|
|||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups/search').reply(200, [
|
||||
normalizeGroup({
|
||||
buildGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
interface Props {
|
||||
title: React.ReactNode | string
|
||||
subtitle: React.ReactNode | string
|
||||
}
|
||||
|
||||
export default ({ title, subtitle }: Props) => (
|
||||
<Stack space={2} className='px-4 py-2' data-testid='no-results'>
|
||||
<Text weight='bold' size='lg'>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
|
@ -1,22 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
export default () => (
|
||||
<Stack space={2} className='px-4 py-2' data-testid='no-results'>
|
||||
<Text weight='bold' size='lg'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.title'
|
||||
defaultMessage='No matches found'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.subtitle'
|
||||
defaultMessage='Try searching for another group.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
|
@ -3,12 +3,12 @@ import React, { useCallback, useState } from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import GroupComp from '../group';
|
||||
import GroupGridItem from '../group-grid-item';
|
||||
import GroupListItem from '../group-list-item';
|
||||
|
||||
interface Props {
|
||||
groupSearchResult: ReturnType<typeof useGroupSearch>
|
||||
|
@ -38,73 +38,20 @@ export default (props: Props) => {
|
|||
};
|
||||
|
||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'pt-4': index !== 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white dark:ring-primary-900'
|
||||
src={group.avatar}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4.5 w-4.5'
|
||||
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{typeof group.members_count !== 'undefined' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<Button theme='primary'>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
</HStack>
|
||||
<GroupListItem group={group} withJoinAction />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||
<div className='pb-4'>
|
||||
<GroupComp group={group} />
|
||||
<GroupGridItem group={group} />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||
|
@ -6,7 +7,7 @@ import { useDebounce, useOwnAccount } from 'soapbox/hooks';
|
|||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { saveGroupSearch } from 'soapbox/utils/groups';
|
||||
|
||||
import NoResultsBlankslate from './no-results-blankslate';
|
||||
import Blankslate from './blankslate';
|
||||
import RecentSearches from './recent-searches';
|
||||
import Results from './results';
|
||||
|
||||
|
@ -25,7 +26,7 @@ export default (props: Props) => {
|
|||
const debouncedValueToSave = debounce(searchValue as string, 1000);
|
||||
|
||||
const groupSearchResult = useGroupSearch(debouncedValue);
|
||||
const { groups, isFetching, isFetched } = groupSearchResult;
|
||||
const { groups, isFetching, isFetched, isError } = groupSearchResult;
|
||||
|
||||
const hasSearchResults = isFetched && groups.length > 0;
|
||||
const hasNoSearchResults = isFetched && groups.length === 0;
|
||||
|
@ -46,8 +47,42 @@ export default (props: Props) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Blankslate
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.error.title'
|
||||
defaultMessage='An error occurred'
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.error.subtitle'
|
||||
defaultMessage='Please try again later.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSearchResults) {
|
||||
return <NoResultsBlankslate />;
|
||||
return (
|
||||
<Blankslate
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.title'
|
||||
defaultMessage='No matches found'
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.subtitle'
|
||||
defaultMessage='Try searching for another group.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSearchResults) {
|
||||
|
|
|
@ -1,52 +1,78 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Carousel, Stack, Text } from 'soapbox/components/ui';
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Carousel, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover';
|
||||
import { useSuggestedGroups } from 'soapbox/queries/groups';
|
||||
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||
|
||||
import Group from './group';
|
||||
import GroupGridItem from './group-grid-item';
|
||||
|
||||
const SuggestedGroups = () => {
|
||||
const { groups, isFetching } = useSuggestedGroups();
|
||||
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
||||
const isEmpty = (isFetched && groups.length === 0) || isError;
|
||||
|
||||
const [groupCover, setGroupCover] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<Text size='xl' weight='bold'>
|
||||
Suggested For You
|
||||
</Text>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text size='xl' weight='bold'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.suggested.title'
|
||||
defaultMessage='Suggested For You'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Group
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
<Link to='/groups/suggested'>
|
||||
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.suggested.show_more'
|
||||
defaultMessage='Show More'
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
</HStack>
|
||||
|
||||
{isEmpty ? (
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.suggested.empty'
|
||||
defaultMessage='Unable to fetch suggested groups at this time. Please check back later.'
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Carousel
|
||||
itemWidth={250}
|
||||
itemCount={groups.length}
|
||||
controlsHeight={groupCover?.clientHeight}
|
||||
>
|
||||
{({ width }: { width: number }) => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='relative flex shrink-0 flex-col space-y-2 px-0.5'
|
||||
style={{ width: width || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderGroupDiscover />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupGridItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
width={width}
|
||||
ref={setGroupCover}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Carousel>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Divider, HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { useFeatures } from 'soapbox/hooks';
|
||||
import { usePendingGroups } from 'soapbox/queries/groups';
|
||||
|
||||
export default () => {
|
||||
const features = useFeatures();
|
||||
|
||||
const { groups, isFetching } = usePendingGroups();
|
||||
|
||||
if (!features.groupsPending || isFetching || groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link to='/groups/pending-requests' className='group' data-testid='pending-groups-row'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<div className='rounded-full bg-primary-200 p-3 text-primary-500 dark:bg-primary-800 dark:text-primary-200'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/exclamation-circle.svg')}
|
||||
className='h-7 w-7'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text weight='bold' size='md'>
|
||||
<FormattedMessage
|
||||
id='groups.pending.count'
|
||||
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
|
||||
values={{ number: groups.length }}
|
||||
/>
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-right.svg')}
|
||||
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
|
||||
/>
|
||||
</HStack>
|
||||
</Link>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -11,30 +11,11 @@ import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissio
|
|||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
import PendingGroupsRow from './components/pending-groups-row';
|
||||
import TabBar, { TabItems } from './components/tab-bar';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
const EmptyMessage = () => (
|
||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const Groups: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
@ -47,11 +28,45 @@ const Groups: React.FC = () => {
|
|||
dispatch(openModal('MANAGE_GROUP'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
const renderBlankslate = () => (
|
||||
<Stack space={4} alignItems='center' justifyContent='center' className='py-6'>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.title'
|
||||
defaultMessage='No Groups yet'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.empty.subtitle'
|
||||
defaultMessage='Start discovering groups to join or create your own.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end xl:hidden'
|
||||
className='self-center'
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
>
|
||||
<FormattedMessage id='new_group_panel.action' defaultMessage='Create Group' />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space={4}>
|
||||
{features.groupsDiscovery && (
|
||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||
)}
|
||||
|
||||
{canCreateGroup && (
|
||||
<Button
|
||||
className='xl:hidden'
|
||||
icon={require('@tabler/icons/circles.svg')}
|
||||
onClick={createGroup}
|
||||
theme='secondary'
|
||||
|
@ -61,14 +76,13 @@ const Groups: React.FC = () => {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{features.groupsDiscovery && (
|
||||
<TabBar activeTab={TabItems.MY_GROUPS} />
|
||||
)}
|
||||
<PendingGroupsRow />
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={<EmptyMessage />}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
emptyMessage={renderBlankslate()}
|
||||
emptyMessageCard={false}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && groups.length === 0}
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import GroupCard from 'soapbox/components/group-card';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import { usePendingGroups } from 'soapbox/queries/groups';
|
||||
|
||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'groups.pending.label', defaultMessage: 'Pending Requests' },
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { groups, isLoading } = usePendingGroups();
|
||||
|
||||
|
||||
const renderBlankslate = () => (
|
||||
<Stack
|
||||
space={4}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
className='py-6'
|
||||
data-testid='pending-requests-blankslate'
|
||||
>
|
||||
<Stack space={2} className='max-w-sm'>
|
||||
<Text size='2xl' weight='bold' tag='h2' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.pending.empty.title'
|
||||
defaultMessage='No pending requests'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text size='sm' theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='groups.pending.empty.subtitle'
|
||||
defaultMessage='You have no pending requests at this time.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.label)}>
|
||||
<ScrollableList
|
||||
emptyMessage={renderBlankslate()}
|
||||
emptyMessageCard={false}
|
||||
isLoading={isLoading}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
placeholderComponent={PlaceholderGroupCard}
|
||||
placeholderCount={3}
|
||||
scrollKey='pending-group-requests'
|
||||
showLoading={isLoading && groups.length === 0}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Link key={group.id} to={`/groups/${group.id}`}>
|
||||
<GroupCard group={group} />
|
||||
</Link>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,114 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
|
||||
import { Column, HStack, Icon } from 'soapbox/components/ui';
|
||||
import { usePopularGroups } from 'soapbox/hooks/api/usePopularGroups';
|
||||
|
||||
import GroupGridItem from './components/discover/group-grid-item';
|
||||
import GroupListItem from './components/discover/group-list-item';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'groups.popular.label', defaultMessage: 'Popular Groups' },
|
||||
});
|
||||
|
||||
enum Layout {
|
||||
LIST = 'LIST',
|
||||
GRID = 'GRID'
|
||||
}
|
||||
|
||||
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||
});
|
||||
|
||||
|
||||
const Popular: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||
|
||||
const { groups, hasNextPage, fetchNextPage } = usePopularGroups();
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'pt-4': index !== 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GroupListItem group={group} withJoinAction />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||
<div className='pb-4'>
|
||||
<GroupGridItem group={group} />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
label={intl.formatMessage(messages.label)}
|
||||
action={
|
||||
<HStack alignItems='center'>
|
||||
<button onClick={() => setLayout(Layout.LIST)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-list.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.LIST,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button onClick={() => setLayout(Layout.GRID)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-grid.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.GRID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
{layout === Layout.LIST ? (
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupList(group, index)}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||
components={{
|
||||
Item: (props) => (
|
||||
<div {...props} className='w-1/2 flex-none' />
|
||||
),
|
||||
List: GridList,
|
||||
}}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popular;
|
|
@ -0,0 +1,114 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
|
||||
import { Column, HStack, Icon } from 'soapbox/components/ui';
|
||||
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||
|
||||
import GroupGridItem from './components/discover/group-grid-item';
|
||||
import GroupListItem from './components/discover/group-list-item';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'groups.popular.label', defaultMessage: 'Suggested Groups' },
|
||||
});
|
||||
|
||||
enum Layout {
|
||||
LIST = 'LIST',
|
||||
GRID = 'GRID'
|
||||
}
|
||||
|
||||
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||
});
|
||||
|
||||
|
||||
const Suggested: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||
|
||||
const { groups, hasNextPage, fetchNextPage } = useSuggestedGroups();
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'pt-4': index !== 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GroupListItem group={group} withJoinAction />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||
<div className='pb-4'>
|
||||
<GroupGridItem group={group} />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
label={intl.formatMessage(messages.label)}
|
||||
action={
|
||||
<HStack alignItems='center'>
|
||||
<button onClick={() => setLayout(Layout.LIST)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-list.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.LIST,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button onClick={() => setLayout(Layout.GRID)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-grid.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.GRID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
{layout === Layout.LIST ? (
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupList(group, index)}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||
components={{
|
||||
Item: (props) => (
|
||||
<div {...props} className='w-1/2 flex-none' />
|
||||
),
|
||||
List: GridList,
|
||||
}}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Suggested;
|
|
@ -15,6 +15,7 @@ import { openModal } from 'soapbox/actions/modals';
|
|||
import { Button, HStack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { Account } from 'soapbox/schemas';
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -35,7 +36,7 @@ const messages = defineMessages({
|
|||
|
||||
interface IActionButton {
|
||||
/** Target account for the action. */
|
||||
account: AccountEntity
|
||||
account: AccountEntity | Account
|
||||
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
|
||||
actionType?: 'muting' | 'blocking' | 'follow_request'
|
||||
/** Displays shorter text on the "Awaiting approval" button. */
|
||||
|
|
|
@ -5,6 +5,7 @@ import { submitGroupEditor } from 'soapbox/actions/groups';
|
|||
import { Modal, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import ConfirmationStep from './steps/confirmation-step';
|
||||
import DetailsStep from './steps/details-step';
|
||||
import PrivacyStep from './steps/privacy-step';
|
||||
|
||||
|
@ -12,16 +13,19 @@ const messages = defineMessages({
|
|||
next: { id: 'manage_group.next', defaultMessage: 'Next' },
|
||||
create: { id: 'manage_group.create', defaultMessage: 'Create' },
|
||||
update: { id: 'manage_group.update', defaultMessage: 'Update' },
|
||||
done: { id: 'manage_group.done', defaultMessage: 'Done' },
|
||||
});
|
||||
|
||||
enum Steps {
|
||||
ONE = 'ONE',
|
||||
TWO = 'TWO',
|
||||
THREE = 'THREE',
|
||||
}
|
||||
|
||||
const manageGroupSteps = {
|
||||
ONE: PrivacyStep,
|
||||
TWO: DetailsStep,
|
||||
THREE: ConfirmationStep,
|
||||
};
|
||||
|
||||
interface IManageGroupModal {
|
||||
|
@ -33,21 +37,24 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
|||
const dispatch = useAppDispatch();
|
||||
|
||||
const id = useAppSelector((state) => state.group_editor.groupId);
|
||||
const [group, setGroup] = useState<any | null>(null);
|
||||
|
||||
const isSubmitting = useAppSelector((state) => state.group_editor.isSubmitting);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Steps>(id ? Steps.TWO : Steps.ONE);
|
||||
|
||||
const onClickClose = () => {
|
||||
const handleClose = () => {
|
||||
onClose('MANAGE_GROUP');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
dispatch(submitGroupEditor(true));
|
||||
return dispatch(submitGroupEditor(true));
|
||||
};
|
||||
|
||||
const confirmationText = useMemo(() => {
|
||||
switch (currentStep) {
|
||||
case Steps.THREE:
|
||||
return intl.formatMessage(messages.done);
|
||||
case Steps.TWO:
|
||||
return intl.formatMessage(id ? messages.update : messages.create);
|
||||
default:
|
||||
|
@ -61,8 +68,15 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
|||
setCurrentStep(Steps.TWO);
|
||||
break;
|
||||
case Steps.TWO:
|
||||
handleSubmit();
|
||||
onClose();
|
||||
handleSubmit()
|
||||
.then((group) => {
|
||||
setCurrentStep(Steps.THREE);
|
||||
setGroup(group);
|
||||
})
|
||||
.catch(() => {});
|
||||
break;
|
||||
case Steps.THREE:
|
||||
handleClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
@ -80,10 +94,11 @@ const ManageGroupModal: React.FC<IManageGroupModal> = ({ onClose }) => {
|
|||
confirmationText={confirmationText}
|
||||
confirmationDisabled={isSubmitting}
|
||||
confirmationFullWidth
|
||||
onClose={onClickClose}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Stack space={2}>
|
||||
<StepToRender />
|
||||
{/* @ts-ignore */}
|
||||
<StepToRender group={group} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Avatar, Divider, HStack, Stack, Text, Button } from 'soapbox/components/ui';
|
||||
|
||||
interface IConfirmationStep {
|
||||
group: any
|
||||
}
|
||||
|
||||
const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
|
||||
const handleCopyLink = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(group.uri);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.share({
|
||||
text: group.display_name,
|
||||
url: group.uri,
|
||||
}).catch((e) => {
|
||||
if (e.name !== 'AbortError') console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={9}>
|
||||
<Stack space={3}>
|
||||
<Stack>
|
||||
<label
|
||||
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
|
||||
>
|
||||
{group.header && <img className='h-full w-full object-cover' src={group.header} alt='' />}
|
||||
</label>
|
||||
|
||||
<label className='mx-auto -mt-10 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
|
||||
{group.avatar && <Avatar src={group.avatar} size={80} />}
|
||||
</label>
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text size='2xl' weight='bold' align='center'>{group.display_name}</Text>
|
||||
<Text size='md' className='mx-auto max-w-sm'>{group.note}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack space={4}>
|
||||
<Text size='3xl' weight='bold' align='center'>
|
||||
<FormattedMessage id='manage_group.confirmation.title' defaultMessage='You’re all set!' />
|
||||
</Text>
|
||||
|
||||
<Stack space={5}>
|
||||
<InfoListItem number={1}>
|
||||
<FormattedMessage
|
||||
id='manage_group.confirmation.info_1'
|
||||
defaultMessage='As the owner of this group, you can assign staff, delete posts and much more.'
|
||||
/>
|
||||
</InfoListItem>
|
||||
|
||||
<InfoListItem number={2}>
|
||||
<FormattedMessage
|
||||
id='manage_group.confirmation.info_2'
|
||||
defaultMessage="Post the group's first post and get the conversation started."
|
||||
/>
|
||||
</InfoListItem>
|
||||
|
||||
<InfoListItem number={3}>
|
||||
<FormattedMessage
|
||||
id='manage_group.confirmation.info_3'
|
||||
defaultMessage='Share your new group with friends, family and followers to grow its membership.'
|
||||
/>
|
||||
</InfoListItem>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<HStack space={2} justifyContent='center'>
|
||||
{('share' in navigator) && (
|
||||
<Button onClick={handleShare} theme='transparent' icon={require('@tabler/icons/share.svg')} className='text-primary-600'>
|
||||
<FormattedMessage id='manage_group.confirmation.share' defaultMessage='Share this group' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onClick={handleCopyLink} theme='transparent' icon={require('@tabler/icons/link.svg')} className='text-primary-600'>
|
||||
<FormattedMessage id='manage_group.confirmation.copy' defaultMessage='Copy link' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface IInfoListNumber {
|
||||
number: number
|
||||
}
|
||||
|
||||
const InfoListNumber: React.FC<IInfoListNumber> = ({ number }) => {
|
||||
return (
|
||||
<div className='flex h-7 w-7 items-center justify-center rounded-full border border-gray-200'>
|
||||
<Text theme='primary' size='sm' weight='bold'>{number}</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IInfoListItem {
|
||||
number: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const InfoListItem: React.FC<IInfoListItem> = ({ number, children }) => {
|
||||
return (
|
||||
<HStack space={3}>
|
||||
<div><InfoListNumber number={number} /></div>
|
||||
<div>{children}</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationStep;
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from 'soapbox/actions/groups';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { Avatar, Form, FormGroup, HStack, Input, Text, Textarea } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
|
||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||
import resizeImage from 'soapbox/utils/resize-image';
|
||||
|
||||
|
@ -37,17 +37,17 @@ const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }
|
|||
className={clsx('absolute top-0 h-full w-full transition-opacity', {
|
||||
'opacity-0 hover:opacity-90 bg-primary-100 dark:bg-gray-800': src,
|
||||
})}
|
||||
space={3}
|
||||
space={1.5}
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/photo-plus.svg')}
|
||||
className='h-7 w-7'
|
||||
className='h-4.5 w-4.5'
|
||||
/>
|
||||
|
||||
<Text size='sm' theme='primary' weight='semibold' transform='uppercase'>
|
||||
<FormattedMessage id='compose_event.upload_banner' defaultMessage='Upload photo' />
|
||||
<Text size='md' theme='primary' weight='semibold'>
|
||||
<FormattedMessage id='group.upload_banner' defaultMessage='Upload photo' />
|
||||
</Text>
|
||||
|
||||
<input
|
||||
|
@ -65,8 +65,8 @@ const HeaderPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }
|
|||
|
||||
const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }) => {
|
||||
return (
|
||||
<label className='absolute left-1/2 bottom-0 h-[72px] w-[72px] -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
|
||||
{src && <Avatar src={src} size={72} />}
|
||||
<label className='absolute left-1/2 bottom-0 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
|
||||
{src && <Avatar src={src} size={80} />}
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
|
@ -77,7 +77,7 @@ const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }
|
|||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/camera-plus.svg')}
|
||||
className='h-7 w-7 text-white'
|
||||
className='h-5 w-5 text-white'
|
||||
/>
|
||||
</HStack>
|
||||
<span className='sr-only'>Upload avatar</span>
|
||||
|
@ -96,6 +96,7 @@ const AvatarPicker: React.FC<IMediaInput> = ({ src, onChange, accept, disabled }
|
|||
const DetailsStep = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = useInstance();
|
||||
|
||||
const groupId = useAppSelector((state) => state.group_editor.groupId);
|
||||
const isUploading = useAppSelector((state) => state.group_editor.isUploading);
|
||||
|
@ -146,7 +147,6 @@ const DetailsStep = () => {
|
|||
});
|
||||
}, [groupId]);
|
||||
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className='relative mb-12 flex'>
|
||||
|
@ -161,6 +161,7 @@ const DetailsStep = () => {
|
|||
placeholder={intl.formatMessage(messages.groupNamePlaceholder)}
|
||||
value={name}
|
||||
onChange={onChangeName}
|
||||
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_name']))}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
|
@ -171,6 +172,7 @@ const DetailsStep = () => {
|
|||
placeholder={intl.formatMessage(messages.groupDescriptionPlaceholder)}
|
||||
value={description}
|
||||
onChange={onChangeDescription}
|
||||
maxLength={Number(instance.configuration.getIn(['groups', 'max_characters_description']))}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
|
|
|
@ -18,8 +18,13 @@ const PrivacyStep = () => {
|
|||
return (
|
||||
<>
|
||||
<Stack className='mx-auto max-w-sm' space={2}>
|
||||
<img
|
||||
className='mx-auto w-32'
|
||||
src={require('assets/images/group.svg')}
|
||||
alt=''
|
||||
/>
|
||||
<Text size='3xl' weight='bold' align='center'>
|
||||
<FormattedMessage id='manage_group.get_started' defaultMessage="Let's get started!" />
|
||||
<FormattedMessage id='manage_group.get_started' defaultMessage='Let’s get started!' />
|
||||
</Text>
|
||||
<Text size='lg' theme='muted' align='center'>
|
||||
<FormattedMessage id='manage_group.tagline' defaultMessage='Groups connect you with others based on shared interests.' />
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Widget } from 'soapbox/components/ui';
|
||||
import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item';
|
||||
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||
import { useSuggestedGroups } from 'soapbox/hooks/api/useSuggestedGroups';
|
||||
|
||||
const SuggestedGroupsPanel = () => {
|
||||
const { groups, isFetching, isFetched, isError } = useSuggestedGroups();
|
||||
const isEmpty = (isFetched && groups.length === 0) || isError;
|
||||
|
||||
if (isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title='Suggested Groups'
|
||||
>
|
||||
{isFetching ? (
|
||||
new Array(3).fill(0).map((_, idx) => (
|
||||
<PlaceholderGroupSearch key={idx} />
|
||||
))
|
||||
) : (
|
||||
groups.slice(0, 3).map((group) => (
|
||||
<GroupListItem group={group} withJoinAction={false} key={group.id} />
|
||||
))
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestedGroupsPanel;
|
|
@ -32,6 +32,7 @@ import EventPage from 'soapbox/pages/event-page';
|
|||
import EventsPage from 'soapbox/pages/events-page';
|
||||
import GroupPage from 'soapbox/pages/group-page';
|
||||
import GroupsPage from 'soapbox/pages/groups-page';
|
||||
import GroupsPendingPage from 'soapbox/pages/groups-pending-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
|
@ -117,6 +118,9 @@ import {
|
|||
Events,
|
||||
Groups,
|
||||
GroupsDiscover,
|
||||
GroupsPopular,
|
||||
GroupsSuggested,
|
||||
PendingGroupRequests,
|
||||
GroupMembers,
|
||||
GroupTimeline,
|
||||
ManageGroup,
|
||||
|
@ -287,6 +291,9 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
|||
|
||||
{features.groups && <WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/discover' exact page={GroupsPage} component={GroupsDiscover} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/popular' exact page={GroupsPendingPage} component={GroupsPopular} content={children} />}
|
||||
{features.groupsDiscovery && <WrappedRoute path='/groups/suggested' exact page={GroupsPendingPage} component={GroupsSuggested} content={children} />}
|
||||
{features.groupsPending && <WrappedRoute path='/groups/pending-requests' exact page={GroupsPendingPage} component={PendingGroupRequests} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id' exact page={GroupPage} component={GroupTimeline} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/members' exact page={GroupPage} component={GroupMembers} content={children} />}
|
||||
{features.groups && <WrappedRoute path='/groups/:id/manage' exact page={DefaultPage} component={ManageGroup} content={children} />}
|
||||
|
|
|
@ -550,6 +550,18 @@ export function GroupsDiscover() {
|
|||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover');
|
||||
}
|
||||
|
||||
export function GroupsPopular() {
|
||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/popular');
|
||||
}
|
||||
|
||||
export function GroupsSuggested() {
|
||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/suggested');
|
||||
}
|
||||
|
||||
export function PendingGroupRequests() {
|
||||
return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests');
|
||||
}
|
||||
|
||||
export function GroupMembers() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../../group/group-members');
|
||||
}
|
||||
|
@ -578,6 +590,10 @@ export function NewGroupPanel() {
|
|||
return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel');
|
||||
}
|
||||
|
||||
export function SuggestedGroupsPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel');
|
||||
}
|
||||
|
||||
export function GroupMediaPanel() {
|
||||
return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel');
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount, normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
import { normalizeAccount, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroupsPath } from '../useGroupsPath';
|
||||
|
||||
|
@ -53,28 +54,21 @@ describe('useGroupsPath()', () => {
|
|||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [
|
||||
normalizeGroup({
|
||||
buildGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
|
||||
mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [
|
||||
buildGroupRelationship({
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should default to the discovery page', async () => {
|
||||
const store = {
|
||||
entities: {
|
||||
Groups: {
|
||||
store: {
|
||||
'1': normalizeGroup({}),
|
||||
},
|
||||
lists: {
|
||||
'': new Set(['1']),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should default to the "My Groups" page', async () => {
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||
|
||||
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
|
||||
const { createEntity } = useEntityActions(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||
{ post: `/api/v1/groups/${group.id}/blocks` },
|
||||
);
|
||||
|
||||
return createEntity;
|
||||
}
|
||||
|
||||
export { useBlockGroupMember };
|
|
@ -0,0 +1,18 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupMembers(groupId: string, role: string) {
|
||||
const { entities, ...result } = useEntities<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupId, role],
|
||||
`/api/v1/groups/${groupId}/memberships?role=${role}`,
|
||||
{ schema: groupMemberSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
groupMembers: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupMembers };
|
|
@ -0,0 +1,33 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useFeatures } from '../useFeatures';
|
||||
import { useGroupRelationships } from '../useGroups';
|
||||
|
||||
function usePopularGroups() {
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.POPULAR_GROUPS, ''],
|
||||
'/api/mock/groups', // '/api/v1/truth/trends/groups'
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { usePopularGroups };
|
|
@ -0,0 +1,33 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useFeatures } from '../useFeatures';
|
||||
import { useGroupRelationships } from '../useGroups';
|
||||
|
||||
function useSuggestedGroups() {
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.SUGGESTED_GROUPS, ''],
|
||||
'/api/mock/groups', // '/api/v1/truth/suggestions/groups'
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useSuggestedGroups };
|
|
@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector';
|
|||
export { useClickOutside } from './useClickOutside';
|
||||
export { useCompose } from './useCompose';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useGetState } from './useGetState';
|
||||
export { useGroup, useGroups } from './useGroups';
|
||||
export { useGroupsPath } from './useGroupsPath';
|
||||
export { useDimensions } from './useDimensions';
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import api from 'soapbox/api';
|
||||
|
||||
import { useAppDispatch } from './useAppDispatch';
|
||||
import { useGetState } from './useGetState';
|
||||
|
||||
/** Use stateful Axios client with auth from Redux. */
|
||||
export const useApi = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return dispatch((_dispatch, getState) => {
|
||||
return api(getState);
|
||||
});
|
||||
const getState = useGetState();
|
||||
return api(getState);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import { parseVersion } from 'soapbox/utils/features';
|
||||
|
||||
import { useInstance } from './useInstance';
|
||||
|
||||
/**
|
||||
* Get the Backend version.
|
||||
*
|
||||
* @returns Backend
|
||||
*/
|
||||
const useBackend = () => {
|
||||
const instance = useInstance();
|
||||
|
||||
return parseVersion(instance.version);
|
||||
};
|
||||
|
||||
export { useBackend };
|
|
@ -0,0 +1,14 @@
|
|||
import { useAppDispatch } from './useAppDispatch';
|
||||
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/**
|
||||
* Provides a `getState()` function to hooks.
|
||||
* You should prefer `useAppSelector` when possible.
|
||||
*/
|
||||
function useGetState() {
|
||||
const dispatch = useAppDispatch();
|
||||
return () => dispatch((_, getState: () => RootState) => getState());
|
||||
}
|
||||
|
||||
export { useGetState };
|
|
@ -0,0 +1,51 @@
|
|||
import { TRUTHSOCIAL } from 'soapbox/utils/features';
|
||||
|
||||
import { useBackend } from './useBackend';
|
||||
|
||||
enum TruthSocialGroupRoles {
|
||||
ADMIN = 'owner',
|
||||
MODERATOR = 'admin',
|
||||
USER = 'user'
|
||||
}
|
||||
|
||||
enum BaseGroupRoles {
|
||||
ADMIN = 'admin',
|
||||
MODERATOR = 'moderator',
|
||||
USER = 'user'
|
||||
}
|
||||
|
||||
const roleMap = {
|
||||
[TruthSocialGroupRoles.ADMIN]: BaseGroupRoles.ADMIN,
|
||||
[TruthSocialGroupRoles.MODERATOR]: BaseGroupRoles.MODERATOR,
|
||||
[TruthSocialGroupRoles.USER]: BaseGroupRoles.USER,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the correct role name depending on the used backend.
|
||||
*
|
||||
* @returns Object
|
||||
*/
|
||||
const useGroupRoles = () => {
|
||||
const version = useBackend();
|
||||
const isTruthSocial = version.software === TRUTHSOCIAL;
|
||||
const selectedRoles = isTruthSocial ? TruthSocialGroupRoles : BaseGroupRoles;
|
||||
|
||||
const normalizeRole = (role: TruthSocialGroupRoles) => {
|
||||
if (isTruthSocial) {
|
||||
return roleMap[role];
|
||||
}
|
||||
|
||||
return role;
|
||||
};
|
||||
|
||||
return {
|
||||
normalizeRole,
|
||||
roles: {
|
||||
admin: selectedRoles.ADMIN,
|
||||
moderator: selectedRoles.MODERATOR,
|
||||
user: selectedRoles.USER,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { useGroupRoles, TruthSocialGroupRoles, BaseGroupRoles };
|
|
@ -1,13 +1,22 @@
|
|||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Group, GroupRelationship } from 'soapbox/types/entities';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
|
||||
|
||||
function useGroups() {
|
||||
const { entities, ...result } = useEntities<Group>(['Group', ''], '/api/v1/groups', { parser: parseGroup });
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, ''],
|
||||
'/api/v1/groups',
|
||||
{ schema: groupSchema },
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => group.set('relationship', relationships[group.id] || null));
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
@ -16,23 +25,35 @@ function useGroups() {
|
|||
}
|
||||
|
||||
function useGroup(groupId: string, refetch = true) {
|
||||
const { entity: group, ...result } = useEntity<Group>(['Group', groupId], `/api/v1/groups/${groupId}`, { parser: parseGroup, refetch });
|
||||
const { entity: group, ...result } = useEntity<Group>(
|
||||
[Entities.GROUPS, groupId],
|
||||
`/api/v1/groups/${groupId}`,
|
||||
{ schema: groupSchema, refetch },
|
||||
);
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
group: group?.set('relationship', relationship || null),
|
||||
group: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function useGroupRelationship(groupId: string) {
|
||||
return useEntity<GroupRelationship>(['GroupRelationship', groupId], `/api/v1/groups/relationships?id[]=${groupId}`, { parser: parseGroupRelationship });
|
||||
return useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||
`/api/v1/groups/relationships?id[]=${groupId}`,
|
||||
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||
);
|
||||
}
|
||||
|
||||
function useGroupRelationships(groupIds: string[]) {
|
||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
||||
const { entities, ...result } = useEntities<GroupRelationship>(['GroupRelationship', q], endpoint, { parser: parseGroupRelationship });
|
||||
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
||||
endpoint,
|
||||
{ schema: groupRelationshipSchema },
|
||||
);
|
||||
|
||||
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||
map[relationship.id] = relationship;
|
||||
|
@ -45,9 +66,4 @@ function useGroupRelationships(groupIds: string[]) {
|
|||
};
|
||||
}
|
||||
|
||||
// HACK: normalizers currently don't have the desired API.
|
||||
// TODO: rewrite normalizers as Zod parsers.
|
||||
const parseGroup = (entity: unknown) => entity ? normalizeGroup(entity as Record<string, any>) : undefined;
|
||||
const parseGroupRelationship = (entity: unknown) => entity ? normalizeGroupRelationship(entity as Record<string, any>) : undefined;
|
||||
|
||||
export { useGroup, useGroups };
|
||||
export { useGroup, useGroups, useGroupRelationships };
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
import MESSAGES from 'soapbox/locales/messages';
|
||||
import { getLocale } from 'soapbox/actions/settings';
|
||||
|
||||
import { useSettings } from './useSettings';
|
||||
import { useAppSelector } from './useAppSelector';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/** Locales which should be presented in right-to-left. */
|
||||
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
|
||||
|
||||
/** Ensure the given locale exists in our codebase */
|
||||
const validLocale = (locale: string): boolean => Object.keys(MESSAGES).includes(locale);
|
||||
|
||||
interface UseLocaleResult {
|
||||
locale: string
|
||||
direction: CSSProperties['direction']
|
||||
|
@ -17,13 +14,7 @@ interface UseLocaleResult {
|
|||
|
||||
/** Get valid locale from settings. */
|
||||
const useLocale = (fallback = 'en'): UseLocaleResult => {
|
||||
const settings = useSettings();
|
||||
const userLocale = settings.get('locale') as unknown;
|
||||
|
||||
const locale =
|
||||
(typeof userLocale === 'string' && validLocale(userLocale))
|
||||
? userLocale
|
||||
: fallback;
|
||||
const locale = useAppSelector((state) => getLocale(state, fallback));
|
||||
|
||||
const direction: CSSProperties['direction'] =
|
||||
RTL_LOCALES.includes(locale)
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { groupSchema, Group, groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas';
|
||||
|
||||
// TODO: there's probably a better way to create these factory functions.
|
||||
// This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock
|
||||
|
||||
function buildGroup(props: Record<string, any> = {}): Group {
|
||||
return groupSchema.parse(Object.assign({
|
||||
id: uuidv4(),
|
||||
}, props));
|
||||
}
|
||||
|
||||
function buildGroupRelationship(props: Record<string, any> = {}): GroupRelationship {
|
||||
return groupRelationshipSchema.parse(Object.assign({
|
||||
id: uuidv4(),
|
||||
}, props));
|
||||
}
|
||||
|
||||
export { buildGroup, buildGroupRelationship };
|
|
@ -329,15 +329,11 @@
|
|||
"column.filters.drop_header": "الحظر بدلا من الإخفاء",
|
||||
"column.filters.drop_hint": "ستختفي المنشورات التي تم ترشيحُها بشكل نهائي، حتى لو أُزيل المُرشّح لاحقًا",
|
||||
"column.filters.expires": "ينتهي خلال",
|
||||
"column.filters.expires_hint": "تواريخ انتهاء الصلاحية غير مدعومة حاليًا",
|
||||
"column.filters.home_timeline": "المنشورات الرئيسية",
|
||||
"column.filters.keyword": "كلمة دلالية أو جملة",
|
||||
"column.filters.notifications": "الإشعارات",
|
||||
"column.filters.public_timeline": "المنشورات العامة",
|
||||
"column.filters.subheading_add_new": "إضافة مُرشّح جديد",
|
||||
"column.filters.subheading_filters": "المُرشّحات الحالية",
|
||||
"column.filters.whole_word_header": "الكلمة كلّها",
|
||||
"column.filters.whole_word_hint": "إذا كانت الكلمة الدلالية أو الجملة مكونةً من أرقامٍ وحروفٍ فقط، فستُرشَّح فقط عند مطابقة الكلمة كلّها",
|
||||
"column.follow_requests": "طلبات المتابعة",
|
||||
"column.followers": "المتابعين",
|
||||
"column.following": "يتابع",
|
||||
|
@ -733,8 +729,6 @@
|
|||
"filters.filters_list_context_label": "سياق المُرشِّح:",
|
||||
"filters.filters_list_drop": "إسقاط",
|
||||
"filters.filters_list_hide": "إخفاء",
|
||||
"filters.filters_list_phrase_label": "كلمة دلالية أو جملة:",
|
||||
"filters.filters_list_whole-word": "الكلمة كلّها",
|
||||
"filters.removed": "حُذف المُرشِّح.",
|
||||
"followRecommendations.heading": "الحسابات المقترحة",
|
||||
"follow_request.authorize": "ترخيص بالوصول",
|
||||
|
@ -744,7 +738,6 @@
|
|||
"gdpr.message": "يستخدم {siteTitle} ملفات الكوكيز لدعم الجلسات وهي تعتبر حيوية لكي يعمل الموقع بشكل صحيح.",
|
||||
"gdpr.title": "موقع {siteTitle} يستخدم الكوكيز",
|
||||
"getting_started.open_source_notice": "{code_name} هو برنامَج مفتوح المصدر. يمكنك المساهمة أو الإبلاغ عن الأخطاء على {code_link} (الإصدار {code_version}).",
|
||||
"group.admin_subheading": "مسؤولي المجموعة",
|
||||
"group.cancel_request": "إلغاء الطلب",
|
||||
"group.group_mod_authorize": "قبول",
|
||||
"group.group_mod_authorize.success": "قُبِلَ @ {name} في المجموعة",
|
||||
|
@ -768,14 +761,12 @@
|
|||
"group.leave": "غادر المجموعة",
|
||||
"group.leave.success": "غادر المجموعة",
|
||||
"group.manage": "إدارة المجموعة",
|
||||
"group.moderator_subheading": "مشرفو المجموعة",
|
||||
"group.privacy.locked": "خاص",
|
||||
"group.privacy.public": "عام",
|
||||
"group.role.admin": "مسؤول",
|
||||
"group.role.moderator": "مشرف",
|
||||
"group.tabs.all": "الكل",
|
||||
"group.tabs.members": "الأعضاء",
|
||||
"group.user_subheading": "المستخدمون",
|
||||
"groups.empty.subtitle": "ابدأ في اكتشاف مجموعات للانضمام إليها أو إنشاء مجموعاتك الخاصة.",
|
||||
"groups.empty.title": "لا توجد مجموعات حتى الآن",
|
||||
"hashtag.column_header.tag_mode.all": "و {additional}",
|
||||
|
|
|
@ -133,15 +133,11 @@
|
|||
"column.filters.drop_header": "Deixa anar en lloc d'amagar",
|
||||
"column.filters.drop_hint": "Els missatges filtrats desapareixeran irreversiblement, encara que el filtre sigui eliminat més tard",
|
||||
"column.filters.expires": "Caduca després de",
|
||||
"column.filters.expires_hint": "Les dates de caducitat no estan suportades actualment",
|
||||
"column.filters.home_timeline": "Línia de temps d'inici",
|
||||
"column.filters.keyword": "Paraula clau o frase",
|
||||
"column.filters.notifications": "Notificacions",
|
||||
"column.filters.public_timeline": "Línia de temps pública",
|
||||
"column.filters.subheading_add_new": "Afegeix un filtre nou",
|
||||
"column.filters.subheading_filters": "Filtres actuals",
|
||||
"column.filters.whole_word_header": "Tota la paraula",
|
||||
"column.filters.whole_word_hint": "Quan la paraula clau o frase és alfanumèrica només s'aplicarà si coincideix amb tota la paraula",
|
||||
"column.follow_requests": "Peticions de seguiment",
|
||||
"column.home": "Inici",
|
||||
"column.import_data": "Importa dades",
|
||||
|
@ -278,8 +274,6 @@
|
|||
"filters.filters_list_context_label": "Filtra els contextos:",
|
||||
"filters.filters_list_drop": "Abandona",
|
||||
"filters.filters_list_hide": "Amaga",
|
||||
"filters.filters_list_phrase_label": "Paraula clau o frase:",
|
||||
"filters.filters_list_whole-word": "Tota la paraula",
|
||||
"follow_request.authorize": "Autoritzar",
|
||||
"follow_request.reject": "Rebutjar",
|
||||
"getting_started.open_source_notice": "{code_name} és un programari de codi obert. Pots contribuir o informar de problemes a {code_link} (v{code_version}).",
|
||||
|
|
|
@ -161,15 +161,11 @@
|
|||
"column.filters.drop_header": "Zahodit místo schovat",
|
||||
"column.filters.drop_hint": "Filtrované příspěvky zmizí navždy, i když filtr později odstraníte",
|
||||
"column.filters.expires": "Vypršet po",
|
||||
"column.filters.expires_hint": "Vyprchávání momentálně není podporováno",
|
||||
"column.filters.home_timeline": "Domovská časová osa",
|
||||
"column.filters.keyword": "Klíčové slovo nebo fráze",
|
||||
"column.filters.notifications": "Oznámení",
|
||||
"column.filters.public_timeline": "Federovaná časová osa",
|
||||
"column.filters.subheading_add_new": "Přidat filter",
|
||||
"column.filters.subheading_filters": "Současné filtry",
|
||||
"column.filters.whole_word_header": "Celé slovo",
|
||||
"column.filters.whole_word_hint": "Filtr bude aplikován, pokud se hledaný řetězec bude shodovat s celým jedním slovem v příspěvku. Pokud se bude shodovat pouze s částí slova, aplikován nebude. (Klíčové slovo tu musí obsahovat výhradně písmena a číslice.)",
|
||||
"column.follow_requests": "Požadavky o sledování",
|
||||
"column.followers": "Sledující",
|
||||
"column.following": "Sledovaní",
|
||||
|
@ -345,8 +341,6 @@
|
|||
"filters.filters_list_context_label": "Kontexty filtru:",
|
||||
"filters.filters_list_drop": "Zahodit",
|
||||
"filters.filters_list_hide": "Schovat",
|
||||
"filters.filters_list_phrase_label": "Klíčové slovo či fráze:",
|
||||
"filters.filters_list_whole-word": "Celé slovo",
|
||||
"follow_request.authorize": "Autorizovat",
|
||||
"follow_request.reject": "Odmítnout",
|
||||
"getting_started.open_source_notice": "{code_name} je otevřený software. Na GitLabu k němu můžete přispět nebo nahlásit chyby: {code_link} (v{code_version}).",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Ceisiadau dilyn",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Caniatau",
|
||||
|
|
|
@ -307,15 +307,11 @@
|
|||
"column.filters.drop_header": "Entfernen statt verstecken",
|
||||
"column.filters.drop_hint": "Gefilterte Beiträge bleiben dauerhaft unsichtbar, auch wenn der Filter später entfernt wird",
|
||||
"column.filters.expires": "Gültig bis",
|
||||
"column.filters.expires_hint": "Ablaufdaten werden zurzeit nicht unterstüzt",
|
||||
"column.filters.home_timeline": "Persönliche Timeline",
|
||||
"column.filters.keyword": "Stichwort oder Wortfolge",
|
||||
"column.filters.notifications": "Benachrichtgungen",
|
||||
"column.filters.public_timeline": "Öffentliche Zeitleiste",
|
||||
"column.filters.subheading_add_new": "Filter hinzufügen",
|
||||
"column.filters.subheading_filters": "Bestehnde Filter",
|
||||
"column.filters.whole_word_header": "Ganzes Wort",
|
||||
"column.filters.whole_word_hint": "Wenn das Schlüsselwort oder die Phrase ausschließlich alphanumerisch ist, wird der Filter ausschließlich auf das gesamte Wort angewandt",
|
||||
"column.follow_requests": "Follower-Anfragen",
|
||||
"column.followers": "Follower",
|
||||
"column.following": "Gefolgte",
|
||||
|
@ -707,8 +703,6 @@
|
|||
"filters.filters_list_context_label": "Kontexte filtern:",
|
||||
"filters.filters_list_drop": "Weg damit",
|
||||
"filters.filters_list_hide": "Verstecken",
|
||||
"filters.filters_list_phrase_label": "Stichwort oder Wortfolge:",
|
||||
"filters.filters_list_whole-word": "Ganzes Wort",
|
||||
"filters.removed": "Filter gelöscht.",
|
||||
"followRecommendations.heading": "Vorgeschlagene Profile",
|
||||
"follow_request.authorize": "Bestätigen",
|
||||
|
@ -718,7 +712,6 @@
|
|||
"gdpr.message": "{siteTitle} verwendet Sitzungscookies, die für das Funktionieren der Website unerlässlich sind.",
|
||||
"gdpr.title": "{siteTitle} verwendet Cookies",
|
||||
"getting_started.open_source_notice": "{code_name} ist quelloffene Software. Du kannst auf GitLab unter {code_link} (v{code_version}) mitarbeiten oder Probleme melden.",
|
||||
"group.admin_subheading": "Gruppenadministratoren",
|
||||
"group.cancel_request": "Anfrage zurückziehen",
|
||||
"group.group_mod_authorize": "Annehmen",
|
||||
"group.group_mod_authorize.success": "Aufnahmeanfrage von @{name} annehmen",
|
||||
|
@ -737,21 +730,19 @@
|
|||
"group.group_mod_unblock": "Entblocken",
|
||||
"group.group_mod_unblock.success": "@{name} in der Gruppe entblockt",
|
||||
"group.header.alt": "Gruppentitel",
|
||||
"group.join.private": "Mitgliedschaft in der Gruppe anfragen",
|
||||
"group.join.public": "Gruppe beitreten",
|
||||
"group.join.request_success": "Mitgliedschaft in der Gruppe angefragt",
|
||||
"group.join.success": "Gruppe beigetreten",
|
||||
"group.leave": "Gruppe verlassen",
|
||||
"group.leave.success": "Gruppe verlassen",
|
||||
"group.manage": "Gruppe verwalten",
|
||||
"group.moderator_subheading": "Moderator:innen der Gruppe",
|
||||
"group.privacy.locked": "Privat",
|
||||
"group.privacy.public": "Öffentlich",
|
||||
"group.join.private": "Mitgliedschaft in der Gruppe anfragen",
|
||||
"group.role.admin": "Administrator:in",
|
||||
"group.role.moderator": "Moderator:in",
|
||||
"group.tabs.all": "Alle",
|
||||
"group.tabs.members": "Mitglieder",
|
||||
"group.user_subheading": "Nutzer:innen",
|
||||
"groups.empty.subtitle": "Entdecke Gruppen zum teilnehmen oder erstelle deine eigene.",
|
||||
"groups.empty.title": "Noch keine Gruppen",
|
||||
"hashtag.column_header.tag_mode.all": "und {additional}",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Αιτήματα ακολούθησης",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Ενέκρινε",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "𐑛𐑮𐑪𐑐 𐑦𐑯𐑕𐑑𐑧𐑛 𐑝 𐑣𐑲𐑛",
|
||||
"column.filters.drop_hint": "𐑓𐑦𐑤𐑑𐑼𐑛 𐑐𐑴𐑕𐑑𐑕 𐑢𐑦𐑤 𐑛𐑦𐑕𐑩𐑐𐑽 𐑦𐑮𐑦𐑝𐑻𐑕𐑩𐑚𐑤𐑦, 𐑰𐑝𐑩𐑯 𐑦𐑓 𐑓𐑦𐑤𐑑𐑼 𐑦𐑟 𐑤𐑱𐑑𐑼 𐑮𐑦𐑥𐑵𐑝𐑛",
|
||||
"column.filters.expires": "𐑦𐑒𐑕𐑐𐑲𐑼 𐑭𐑓𐑑𐑼",
|
||||
"column.filters.expires_hint": "𐑧𐑒𐑕𐑐𐑦𐑮𐑱𐑖𐑩𐑯 𐑛𐑱𐑑𐑕 𐑸 𐑯𐑪𐑑 𐑒𐑳𐑮𐑩𐑯𐑑𐑤𐑦 𐑕𐑩𐑐𐑹𐑑𐑩𐑛",
|
||||
"column.filters.home_timeline": "𐑣𐑴𐑥 𐑑𐑲𐑥𐑤𐑲𐑯",
|
||||
"column.filters.keyword": "𐑒𐑰𐑢𐑻𐑛 𐑹 𐑓𐑮𐑱𐑟",
|
||||
"column.filters.notifications": "𐑯𐑴𐑑𐑦𐑓𐑦𐑒𐑱𐑖𐑩𐑯𐑟",
|
||||
"column.filters.public_timeline": "𐑐𐑳𐑚𐑤𐑦𐑒 𐑑𐑲𐑥𐑤𐑲𐑯",
|
||||
"column.filters.subheading_add_new": "𐑨𐑛 𐑯𐑿 𐑓𐑦𐑤𐑑𐑼",
|
||||
"column.filters.subheading_filters": "𐑒𐑳𐑮𐑩𐑯𐑑 𐑓𐑦𐑤𐑑𐑼𐑟",
|
||||
"column.filters.whole_word_header": "𐑣𐑴𐑤 𐑢𐑻𐑛",
|
||||
"column.filters.whole_word_hint": "𐑢𐑧𐑯 𐑞 𐑒𐑰𐑢𐑻𐑛 𐑹 𐑓𐑮𐑱𐑟 𐑦𐑟 𐑨𐑤𐑓𐑩𐑯𐑿𐑥𐑧𐑮𐑦𐑒 𐑴𐑯𐑤𐑦, 𐑦𐑑 𐑢𐑦𐑤 𐑴𐑯𐑤𐑦 𐑚𐑰 𐑩𐑐𐑤𐑲𐑛 𐑦𐑓 𐑦𐑑 𐑥𐑨𐑗𐑩𐑟 𐑞 𐑣𐑴𐑤 𐑢𐑻𐑛",
|
||||
"column.follow_requests": "𐑓𐑪𐑤𐑴 𐑮𐑦𐑒𐑢𐑧𐑕𐑑𐑕",
|
||||
"column.followers": "𐑓𐑪𐑤𐑴𐑼𐑟",
|
||||
"column.following": "𐑓𐑪𐑤𐑴𐑦𐑙",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "𐑓𐑦𐑤𐑑𐑼 𐑒𐑪𐑯𐑑𐑧𐑒𐑕𐑑𐑕:",
|
||||
"filters.filters_list_drop": "𐑛𐑮𐑪𐑐",
|
||||
"filters.filters_list_hide": "𐑣𐑲𐑛",
|
||||
"filters.filters_list_phrase_label": "𐑒𐑰𐑢𐑻𐑛 𐑹 𐑓𐑮𐑱𐑟:",
|
||||
"filters.filters_list_whole-word": "𐑣𐑴𐑤 𐑢𐑻𐑛",
|
||||
"filters.removed": "𐑓𐑦𐑤𐑑𐑼 𐑛𐑦𐑤𐑰𐑑𐑩𐑛.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "𐑷𐑔𐑼𐑲𐑟",
|
||||
|
|
|
@ -470,7 +470,7 @@
|
|||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||
"confirmations.block_from_group.confirm": "Block",
|
||||
"confirmations.block_from_group.heading": "Block group member",
|
||||
"confirmations.block_from_group.message": "Are you sure you want to block @{name} from interacting with this group?",
|
||||
"confirmations.block_from_group.message": "Are you sure you want to ban @{name} from the group?",
|
||||
"confirmations.cancel.confirm": "Discard",
|
||||
"confirmations.cancel.heading": "Discard post",
|
||||
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
||||
|
@ -765,12 +765,11 @@
|
|||
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
||||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Group administrators",
|
||||
"group.cancel_request": "Cancel Request",
|
||||
"group.group_mod_authorize": "Accept",
|
||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||
"group.group_mod_block": "Block @{name} from group",
|
||||
"group.group_mod_block.success": "Blocked @{name} from group",
|
||||
"group.group_mod_block": "Ban from group",
|
||||
"group.group_mod_block.success": "You have successfully blocked @{name} from the group",
|
||||
"group.group_mod_demote": "Demote @{name}",
|
||||
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
||||
"group.group_mod_kick": "Kick @{name} from group",
|
||||
|
@ -787,18 +786,26 @@
|
|||
"group.join.private": "Request Access",
|
||||
"group.join.public": "Join Group",
|
||||
"group.join.request_success": "Requested to join the group",
|
||||
"group.join.success": "Joined the group",
|
||||
"group.join.success": "Group joined successfully!",
|
||||
"group.leave": "Leave Group",
|
||||
"group.leave.success": "Left the group",
|
||||
"group.manage": "Manage Group",
|
||||
"group.moderator_subheading": "Group moderators",
|
||||
"group.privacy.locked": "Private",
|
||||
"group.privacy.locked.full": "Private Group",
|
||||
"group.privacy.locked.info": "Discoverable. Users can join after their request is approved.",
|
||||
"group.privacy.public": "Public",
|
||||
"group.privacy.public.full": "Public Group",
|
||||
"group.privacy.public.info": "Discoverable. Anyone can join.",
|
||||
"group.role.admin": "Admin",
|
||||
"group.role.moderator": "Moderator",
|
||||
"group.tabs.all": "All",
|
||||
"group.tabs.members": "Members",
|
||||
"group.user_subheading": "Users",
|
||||
"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",
|
||||
"groups.discover.popular.title": "Popular Groups",
|
||||
"groups.discover.search.error.subtitle": "Please try again later.",
|
||||
"groups.discover.search.error.title": "An error occurred",
|
||||
"groups.discover.search.no_results.subtitle": "Try searching for another group.",
|
||||
"groups.discover.search.no_results.title": "No matches found",
|
||||
"groups.discover.search.placeholder": "Search",
|
||||
|
@ -808,8 +815,16 @@
|
|||
"groups.discover.search.recent_searches.title": "Recent searches",
|
||||
"groups.discover.search.results.groups": "Groups",
|
||||
"groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}",
|
||||
"groups.discover.suggested.empty": "Unable to fetch suggested groups at this time. Please check back later.",
|
||||
"groups.discover.suggested.show_more": "Show More",
|
||||
"groups.discover.suggested.title": "Suggested For You",
|
||||
"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}}",
|
||||
"groups.pending.empty.subtitle": "You have no pending requests at this time.",
|
||||
"groups.pending.empty.title": "No pending requests",
|
||||
"groups.pending.label": "Pending Requests",
|
||||
"groups.popular.label": "Suggested Groups",
|
||||
"hashtag.column_header.tag_mode.all": "and {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "or {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "without {additional}",
|
||||
|
@ -913,15 +928,22 @@
|
|||
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
||||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Blocked members",
|
||||
"manage_group.confirmation.copy": "Copy link",
|
||||
"manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.",
|
||||
"manage_group.confirmation.info_2": "Post the group's first post and get the conversation started.",
|
||||
"manage_group.confirmation.info_3": "Share your new group with friends, family and followers to grow its membership.",
|
||||
"manage_group.confirmation.share": "Share this group",
|
||||
"manage_group.confirmation.title": "You’re all set!",
|
||||
"manage_group.create": "Create",
|
||||
"manage_group.delete_group": "Delete group",
|
||||
"manage_group.done": "Done",
|
||||
"manage_group.edit_group": "Edit group",
|
||||
"manage_group.edit_success": "The group was edited",
|
||||
"manage_group.fields.description_label": "Description",
|
||||
"manage_group.fields.description_placeholder": "Description",
|
||||
"manage_group.fields.name_label": "Group name (required)",
|
||||
"manage_group.fields.name_placeholder": "Group Name",
|
||||
"manage_group.get_started": "Let's get started!",
|
||||
"manage_group.get_started": "Let’s get started!",
|
||||
"manage_group.next": "Next",
|
||||
"manage_group.pending_requests": "Pending requests",
|
||||
"manage_group.privacy.hint": "These settings cannot be changed later.",
|
||||
|
|
|
@ -280,15 +280,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Solicitudes de seguimiento",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -589,8 +585,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Autorizar",
|
||||
|
|
|
@ -283,6 +283,13 @@
|
|||
"chats.main.blankslate_with_chats.subtitle": "Selecciona uno de tus chats abiertos o escribe un mensaje nuevo.",
|
||||
"chats.main.blankslate_with_chats.title": "Seleccionar un chat",
|
||||
"chats.search_placeholder": "Start a chat with…",
|
||||
"colum.filters.expiration.1800": "30 minutos",
|
||||
"colum.filters.expiration.21600": "6 horas",
|
||||
"colum.filters.expiration.3600": "1 hora",
|
||||
"colum.filters.expiration.43200": "12 horas",
|
||||
"colum.filters.expiration.604800": "1 semana",
|
||||
"colum.filters.expiration.86400": "1 día",
|
||||
"colum.filters.expiration.never": "Nunca",
|
||||
"column.admin.announcements": "Anuncios",
|
||||
"column.admin.awaiting_approval": "En espera de aprobación",
|
||||
"column.admin.create_announcement": "Crear un anuncio",
|
||||
|
@ -321,6 +328,7 @@
|
|||
"column.favourites": "Likes",
|
||||
"column.federation_restrictions": "Federation Restrictions",
|
||||
"column.filters": "Muted words",
|
||||
"column.filters.accounts": "Cuentas",
|
||||
"column.filters.add_new": "Add New Filter",
|
||||
"column.filters.conversations": "Conversations",
|
||||
"column.filters.create_error": "Error adding filter",
|
||||
|
@ -328,16 +336,18 @@
|
|||
"column.filters.delete_error": "Error deleting filter",
|
||||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.edit": "Editar",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.hide_header": "Ocultar por completo",
|
||||
"column.filters.hide_hint": "Ocultar completamente el contenido filtrado, en lugar de mostrar una advertencia",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.keywords": "Frases o palabras clave",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.filters.title": "Título",
|
||||
"column.filters.whole_word": "Palabra entera",
|
||||
"column.follow_requests": "Solicitudes de seguimiento",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -740,11 +750,14 @@
|
|||
"filters.added": "Filter added.",
|
||||
"filters.context_header": "Filter contexts",
|
||||
"filters.context_hint": "One or multiple contexts where the filter should apply",
|
||||
"filters.create_filter": "Crear un filtro",
|
||||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_expired": "Caducado",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.filters_list_hide_completely": "Ocultar el contenido",
|
||||
"filters.filters_list_phrases_label": "Palabras o frases clave:",
|
||||
"filters.filters_list_warn": "Mostrar una advertencia",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Autorizar",
|
||||
|
@ -754,7 +767,6 @@
|
|||
"gdpr.message": "{siteTitle} uses session cookies, which are essential to the website's functioning.",
|
||||
"gdpr.title": "{siteTitle} uses cookies",
|
||||
"getting_started.open_source_notice": "{code_name} es software libre. Puedes contribuir o reportar errores en {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Administradores del grupo",
|
||||
"group.cancel_request": "Cancelar solicitud",
|
||||
"group.group_mod_authorize": "Aceptar",
|
||||
"group.group_mod_authorize.success": "Aceptado @{name} al grupo",
|
||||
|
@ -776,18 +788,22 @@
|
|||
"group.join.private": "Solicitar Acceso",
|
||||
"group.join.public": "Únete al grupo",
|
||||
"group.join.request_success": "Solicitud de unión al grupo",
|
||||
"group.join.success": "Se unió al grupo",
|
||||
"group.join.success": "¡El grupo se unió con éxito!",
|
||||
"group.leave": "Dejar el Grupo",
|
||||
"group.leave.success": "Abandonó el grupo",
|
||||
"group.manage": "Gestionar el Grupo",
|
||||
"group.moderator_subheading": "Moderadores del grupo",
|
||||
"group.privacy.locked": "Privado",
|
||||
"group.privacy.public": "Público",
|
||||
"group.role.admin": "Administrador",
|
||||
"group.role.moderator": "Moderador",
|
||||
"group.tabs.all": "Todos",
|
||||
"group.tabs.members": "Miembros",
|
||||
"group.user_subheading": "Usuarios",
|
||||
"group.upload_banner": "Subir una foto",
|
||||
"groups.discover.popular.empty": "No es posible recuperar los grupos populares en este momento. Vuelve a comprobarlo más tarde.",
|
||||
"groups.discover.popular.show_more": "Ver más",
|
||||
"groups.discover.popular.title": "Grupos populares",
|
||||
"groups.discover.search.error.subtitle": "Por favor, inténtelo más tarde.",
|
||||
"groups.discover.search.error.title": "Se produjo un error",
|
||||
"groups.discover.search.no_results.subtitle": "Intenta buscar otro grupo.",
|
||||
"groups.discover.search.no_results.title": "Sin coincidencias",
|
||||
"groups.discover.search.placeholder": "Buscar",
|
||||
|
@ -797,8 +813,16 @@
|
|||
"groups.discover.search.recent_searches.title": "Últimas búsquedas",
|
||||
"groups.discover.search.results.groups": "Grupos",
|
||||
"groups.discover.search.results.member_count": "{miembros, plural, un {miembro} otro {miembros}}",
|
||||
"groups.discover.suggested.empty": "No es posible recuperar los grupos sugeridos en este momento. Vuelva a comprobarlo más tarde.",
|
||||
"groups.discover.suggested.show_more": "Ver más",
|
||||
"groups.discover.suggested.title": "Recomendado para ti",
|
||||
"groups.empty.subtitle": "Empieza a descubrir los grupos a los que unirte o crea el tuyo propio.",
|
||||
"groups.empty.title": "Aún no hay grupos",
|
||||
"groups.pending.count": "{number, plural, una {# solicitud pendiente} other {# solicitudes pendientes}}",
|
||||
"groups.pending.empty.subtitle": "No tienes solicitudes pendientes en este momento.",
|
||||
"groups.pending.empty.title": "Ninguna solicitud pendiente",
|
||||
"groups.pending.label": "Solicitudes pendientes",
|
||||
"groups.popular.label": "Grupos recomendados",
|
||||
"hashtag.column_header.tag_mode.all": "y {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "o {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "sin {additional}",
|
||||
|
@ -902,8 +926,15 @@
|
|||
"login_external.errors.network_fail": "Connection failed. Is a browser extension blocking it?",
|
||||
"login_form.header": "Sign In",
|
||||
"manage_group.blocked_members": "Miembros bloqueados",
|
||||
"manage_group.confirmation.copy": "Copiar el enlace",
|
||||
"manage_group.confirmation.info_1": "Como propietario de este grupo, puedes asignar personal, eliminar mensajes y mucho más.",
|
||||
"manage_group.confirmation.info_2": "Publica el primer mensaje del grupo y comienza la conversación.",
|
||||
"manage_group.confirmation.info_3": "Comparte tu nuevo grupo con amigos, familiares y seguidores para aumentar el número de miembros.",
|
||||
"manage_group.confirmation.share": "Compartir este grupo",
|
||||
"manage_group.confirmation.title": "¡Ya está todo listo!",
|
||||
"manage_group.create": "Crear",
|
||||
"manage_group.delete_group": "Borrar el grupo",
|
||||
"manage_group.done": "Hecho",
|
||||
"manage_group.edit_group": "Editar el grupo",
|
||||
"manage_group.edit_success": "El grupo ha sido editado",
|
||||
"manage_group.fields.description_label": "Descripción",
|
||||
|
@ -1402,6 +1433,7 @@
|
|||
"status.sensitive_warning": "Contenido sensible",
|
||||
"status.sensitive_warning.subtitle": "This content may not be suitable for all audiences.",
|
||||
"status.share": "Compartir",
|
||||
"status.show_filter_reason": "Mostrar de todos modos",
|
||||
"status.show_less_all": "Mostrar menos para todo",
|
||||
"status.show_more_all": "Mostrar más para todo",
|
||||
"status.show_original": "Show original",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "درخواستهای پیگیری",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "اجازه دهید",
|
||||
|
|
|
@ -307,15 +307,11 @@
|
|||
"column.filters.drop_header": "Supprimer au lieu de cacher",
|
||||
"column.filters.drop_hint": "Les publication filtrées disparaîtront de façon irréversible et ce même si le filtre est supprimé ultérieurement",
|
||||
"column.filters.expires": "Expire après",
|
||||
"column.filters.expires_hint": "Les dates d'expiration ne sont pour l'instant pas supportées",
|
||||
"column.filters.home_timeline": "Fil d'accueil",
|
||||
"column.filters.keyword": "Mot-clé ou phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Fil public",
|
||||
"column.filters.subheading_add_new": "Ajouter un nouveau filtre",
|
||||
"column.filters.subheading_filters": "Filtre actuels",
|
||||
"column.filters.whole_word_header": "Mot entier",
|
||||
"column.filters.whole_word_hint": "Quand le mot-clé ou la phrase est uniquement alphanumérique, cela ne sera appliqué que si cela correspond au mot entier",
|
||||
"column.follow_requests": "Demandes de suivi",
|
||||
"column.followers": "Abonnés",
|
||||
"column.following": "Abonnements",
|
||||
|
@ -680,8 +676,6 @@
|
|||
"filters.filters_list_context_label": "Contexte du filtre :",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Mot-clé ou phrase :",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Accepter",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Follow requests",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Authorize",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "הורד במקום להסתיר",
|
||||
"column.filters.drop_hint": "פוסטים מסוננים ייעלמו באופן בלתי הפיך, גם אם הפילטר יוסר מאוחר יותר",
|
||||
"column.filters.expires": "פג תוקף לאחר",
|
||||
"column.filters.expires_hint": "תאריכי תפוגה אינם נתמכים כעת",
|
||||
"column.filters.home_timeline": "ציר זמן ביתי",
|
||||
"column.filters.keyword": "מילת מפתח או ביטוי",
|
||||
"column.filters.notifications": "התראות",
|
||||
"column.filters.public_timeline": "ציר זמן ציבורי",
|
||||
"column.filters.subheading_add_new": "הוסף פילטר חדש",
|
||||
"column.filters.subheading_filters": "פילטרים נוכחיים",
|
||||
"column.filters.whole_word_header": "מילה שלמה",
|
||||
"column.filters.whole_word_hint": "כאשר מילת המפתח או הביטוי הם אלפאנומריים בלבד, הם יוחלו רק אם הם תואמים לכל המילה.",
|
||||
"column.follow_requests": "בקשות מעקב",
|
||||
"column.followers": "עוקבים",
|
||||
"column.following": "עוקב אחרי",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "הקשרי הפילטר:",
|
||||
"filters.filters_list_drop": "הורד",
|
||||
"filters.filters_list_hide": "הסתר",
|
||||
"filters.filters_list_phrase_label": "מילת מפתח או ביטוי:",
|
||||
"filters.filters_list_whole-word": "מילה שלמה",
|
||||
"filters.removed": "פילטר נמחק.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "קבלה",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Follow requests",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Authorize",
|
||||
|
|
|
@ -305,15 +305,11 @@
|
|||
"column.filters.drop_header": "Ispustite umjesto da sakrijete",
|
||||
"column.filters.drop_hint": "Filtrirani postovi nepovratno će nestati, čak i ako se filter kasnije ukloni",
|
||||
"column.filters.expires": "Istječe nakon",
|
||||
"column.filters.expires_hint": "Datumi isteka trenutno nisu podržani",
|
||||
"column.filters.home_timeline": "Početna vremenska linija",
|
||||
"column.filters.keyword": "Ključna riječ ili izraz",
|
||||
"column.filters.notifications": "Obavijesti",
|
||||
"column.filters.public_timeline": "Javna vremenska linija",
|
||||
"column.filters.subheading_add_new": "Dodaj novi filtar",
|
||||
"column.filters.subheading_filters": "Trenutni filtri",
|
||||
"column.filters.whole_word_header": "Cijela riječ",
|
||||
"column.filters.whole_word_hint": "Kada je ključna riječ ili fraza samo alfanumerička, primijenit će se samo ako odgovara cijeloj riječi",
|
||||
"column.follow_requests": "Zahtjevi za slijeđenje",
|
||||
"column.followers": "Osoba koje prate",
|
||||
"column.following": "Pratim",
|
||||
|
@ -677,8 +673,6 @@
|
|||
"filters.filters_list_context_label": "Filtriraj kontekste:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Cijela riječ",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Predloženi profili",
|
||||
"follow_request.authorize": "Autoriziraj",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Követési kérelmek",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Engedélyezés",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Permintaan mengikuti",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Izinkan",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Demandi di sequado",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Yurizar",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Fella niður í staðinn fyrir að fela",
|
||||
"column.filters.drop_hint": "Síaðar færslur munu hverfa óafturkræft jafnvel þótt sían sé fjarlægð síðar",
|
||||
"column.filters.expires": "Gildistími",
|
||||
"column.filters.expires_hint": "Gildistímar eru ekki studdir eins og er",
|
||||
"column.filters.home_timeline": "Heimatímalína",
|
||||
"column.filters.keyword": "Stikkorð eða setning",
|
||||
"column.filters.notifications": "Tilkynningar",
|
||||
"column.filters.public_timeline": "Sameiginleg tímalína",
|
||||
"column.filters.subheading_add_new": "Bæta við nýrri síu",
|
||||
"column.filters.subheading_filters": "Núverandi síur",
|
||||
"column.filters.whole_word_header": "Heilt orð",
|
||||
"column.filters.whole_word_hint": "Þegar leitarorðið eða setningin er eingöngu tölustafir verður það aðeins notað ef það passar við allt orðið",
|
||||
"column.follow_requests": "Fylgjubeiðnir",
|
||||
"column.followers": "Fylgjendur",
|
||||
"column.following": "Fylgjandi",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Samhengi síu:",
|
||||
"filters.filters_list_drop": "Fella niður",
|
||||
"filters.filters_list_hide": "Fela",
|
||||
"filters.filters_list_phrase_label": "Orð eða setning:",
|
||||
"filters.filters_list_whole-word": "Heilt orð",
|
||||
"filters.removed": "Síu eytt",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Leyfa",
|
||||
|
|
|
@ -329,15 +329,11 @@
|
|||
"column.filters.drop_header": "Cancella anziché nascondere",
|
||||
"column.filters.drop_hint": "Le pubblicazioni spariranno irrimediabilmente, anche dopo aver rimosso il filtro",
|
||||
"column.filters.expires": "Scadenza",
|
||||
"column.filters.expires_hint": "Le date di scadenza non sono pronte",
|
||||
"column.filters.home_timeline": "Timeline locale",
|
||||
"column.filters.keyword": "Parola chiave o frase",
|
||||
"column.filters.notifications": "Notifiche",
|
||||
"column.filters.public_timeline": "Timeline federata",
|
||||
"column.filters.subheading_add_new": "Aggiungi nuovo filtro",
|
||||
"column.filters.subheading_filters": "Filtraggi attivi",
|
||||
"column.filters.whole_word_header": "Parola intera",
|
||||
"column.filters.whole_word_hint": "Quando la parola chiave o la frase è alfanumerica, filtrerà solo se coincide con la parola intera",
|
||||
"column.follow_requests": "Richieste dai Follower",
|
||||
"column.followers": "Follower",
|
||||
"column.following": "Following",
|
||||
|
@ -743,8 +739,6 @@
|
|||
"filters.filters_list_context_label": "Contesto del filtro:",
|
||||
"filters.filters_list_drop": "Cancella",
|
||||
"filters.filters_list_hide": "Nascondi",
|
||||
"filters.filters_list_phrase_label": "Parola chiave o frase:",
|
||||
"filters.filters_list_whole-word": "Parola intera",
|
||||
"filters.removed": "Il filtro è stato eliminato.",
|
||||
"followRecommendations.heading": "Profili in primo piano",
|
||||
"follow_request.authorize": "Autorizza",
|
||||
|
@ -754,7 +748,6 @@
|
|||
"gdpr.message": "{siteTitle} usa i cookie tecnici, quelli essenziali al funzionamento.",
|
||||
"gdpr.title": "{siteTitle} usa i cookie",
|
||||
"getting_started.open_source_notice": "{code_name} è un software open source. Puoi contribuire o segnalare errori su GitLab all'indirizzo {code_link} (v{code_version}).",
|
||||
"group.admin_subheading": "Amministrazione del gruppo",
|
||||
"group.cancel_request": "Cancella richiesta",
|
||||
"group.group_mod_authorize": "Accetta",
|
||||
"group.group_mod_authorize.success": "Hai accettato @{name} nel gruppo",
|
||||
|
@ -780,14 +773,12 @@
|
|||
"group.leave": "Abbandona il gruppo",
|
||||
"group.leave.success": "Hai abbandonato il gruppo",
|
||||
"group.manage": "Gestisci il gruppo",
|
||||
"group.moderator_subheading": "Moderazione del gruppo",
|
||||
"group.privacy.locked": "Privato",
|
||||
"group.privacy.public": "Pubblico",
|
||||
"group.role.admin": "Amministrazione",
|
||||
"group.role.moderator": "Moderazione",
|
||||
"group.tabs.all": "Tutto",
|
||||
"group.tabs.members": "Partecipanti",
|
||||
"group.user_subheading": "Persone",
|
||||
"groups.discover.search.no_results.subtitle": "Prova a cercare un altro gruppo.",
|
||||
"groups.discover.search.no_results.title": "Nessun risultato",
|
||||
"groups.discover.search.placeholder": "Cerca",
|
||||
|
|
|
@ -289,15 +289,11 @@
|
|||
"column.filters.drop_header": "非表示のかわりにドロップ",
|
||||
"column.filters.drop_hint": "フィルタが削除されてもフィルタリングされた投稿は元に戻せなくなります",
|
||||
"column.filters.expires": "有効期限",
|
||||
"column.filters.expires_hint": "有効期限は現在サポートされていません",
|
||||
"column.filters.home_timeline": "ホームタイムライン",
|
||||
"column.filters.keyword": "単語もしくは成句",
|
||||
"column.filters.notifications": "通知",
|
||||
"column.filters.public_timeline": "公開タイムライン",
|
||||
"column.filters.subheading_add_new": "新しいフィルタを追加",
|
||||
"column.filters.subheading_filters": "現在のフィルタ",
|
||||
"column.filters.whole_word_header": "全体一致",
|
||||
"column.filters.whole_word_hint": "単語もしくは成句が英数字のみの場合、単語全体に一致する場合のみ適用されます",
|
||||
"column.follow_requests": "フォローリクエスト",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -598,8 +594,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "許可",
|
||||
|
|
|
@ -230,15 +230,11 @@
|
|||
"column.filters.drop_header": "Drop instead of hide",
|
||||
"column.filters.drop_hint": "Filtered posts will disappear irreversibly, even if filter is later removed",
|
||||
"column.filters.expires": "Expire after",
|
||||
"column.filters.expires_hint": "Expiration dates are not currently supported",
|
||||
"column.filters.home_timeline": "Home timeline",
|
||||
"column.filters.keyword": "Keyword or phrase",
|
||||
"column.filters.notifications": "Notifications",
|
||||
"column.filters.public_timeline": "Public timeline",
|
||||
"column.filters.subheading_add_new": "Add New Filter",
|
||||
"column.filters.subheading_filters": "Current Filters",
|
||||
"column.filters.whole_word_header": "Whole word",
|
||||
"column.filters.whole_word_hint": "When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word",
|
||||
"column.follow_requests": "Жазылу сұранымдары",
|
||||
"column.followers": "Followers",
|
||||
"column.following": "Following",
|
||||
|
@ -539,8 +535,6 @@
|
|||
"filters.filters_list_context_label": "Filter contexts:",
|
||||
"filters.filters_list_drop": "Drop",
|
||||
"filters.filters_list_hide": "Hide",
|
||||
"filters.filters_list_phrase_label": "Keyword or phrase:",
|
||||
"filters.filters_list_whole-word": "Whole word",
|
||||
"filters.removed": "Filter deleted.",
|
||||
"followRecommendations.heading": "Suggested Profiles",
|
||||
"follow_request.authorize": "Авторизация",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue