Merge remote-tracking branch 'soapbox/develop' into follow-hashtags

environments/review-follow-has-4jhse4/deployments/3360
marcin mikołajczak 1 year ago
commit da6be7ba4c

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Posts: Support posts filtering on recent Mastodon versions
- Reactions: Support custom emoji reactions
- Compatbility: Support Mastodon v2 timeline filters.
- Compatbility: Preliminary support for Ditto backend.
- Posts: Support dislikes on Friendica.
- UI: added a character counter to some textareas.
@ -31,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 18n: fixed Chinese language being detected from the browser.
- Conversations: fixed pagination (Mastodon).
- Compatibility: fix version parsing for Friendica.
- UI: fixed various overflow issues related to long usernames.
- UI: fixed display of Markdown code blocks in the reply indicator.
## [3.2.0] - 2023-02-15

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ReducerRecord, EditRecord } from 'soapbox/reducers/account-notes';
import { normalizeAccount, normalizeRelationship } from '../../normalizers';
import { normalizeAccount } from '../../normalizers';
import { changeAccountNoteComment, initAccountNoteModal, submitAccountNote } from '../account-notes';
import type { Account } from 'soapbox/types/entities';
@ -66,7 +67,7 @@ describe('initAccountNoteModal()', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ '1': normalizeRelationship({ note: 'hello' }) }));
.set('relationships', ImmutableMap({ '1': buildRelationship({ note: 'hello' }) }));
store = mockStore(state);
});

@ -1,10 +1,11 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { buildRelationship } from 'soapbox/jest/factory';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { ListRecord, ReducerRecord } from 'soapbox/reducers/user-lists';
import { normalizeAccount, normalizeInstance, normalizeRelationship } from '../../normalizers';
import { normalizeAccount, normalizeInstance } from '../../normalizers';
import {
authorizeFollowRequest,
blockAccount,
@ -1340,7 +1341,7 @@ describe('fetchRelationships()', () => {
describe('without newAccountIds', () => {
beforeEach(() => {
const state = rootState
.set('relationships', ImmutableMap({ [id]: normalizeRelationship({}) }))
.set('relationships', ImmutableMap({ [id]: buildRelationship() }))
.set('me', '123');
store = mockStore(state);
});

@ -242,7 +242,8 @@ export const fetchOwnAccounts = () =>
return state.auth.users.forEach((user) => {
const account = state.accounts.get(user.id);
if (!account) {
dispatch(verifyCredentials(user.access_token, user.url));
dispatch(verifyCredentials(user.access_token, user.url))
.catch(() => console.warn(`Failed to load account: ${user.url}`));
}
});
};

@ -4,7 +4,6 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedGroups, importFetchedAccounts } from './importer';
import { deleteFromTimelines } from './timelines';
import type { AxiosError } from 'axios';
import type { GroupRole } from 'soapbox/reducers/group-memberships';
@ -35,10 +34,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_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';
const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST';
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL';
@ -206,36 +201,6 @@ const fetchGroupRelationshipsFail = (error: AxiosError) => ({
skipNotFound: true,
});
const groupDeleteStatus = (groupId: string, statusId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupDeleteStatusRequest(groupId, statusId));
return api(getState).delete(`/api/v1/groups/${groupId}/statuses/${statusId}`)
.then(() => {
dispatch(deleteFromTimelines(statusId));
dispatch(groupDeleteStatusSuccess(groupId, statusId));
}).catch(err => dispatch(groupDeleteStatusFail(groupId, statusId, err)));
};
const groupDeleteStatusRequest = (groupId: string, statusId: string) => ({
type: GROUP_DELETE_STATUS_REQUEST,
groupId,
statusId,
});
const groupDeleteStatusSuccess = (groupId: string, statusId: string) => ({
type: GROUP_DELETE_STATUS_SUCCESS,
groupId,
statusId,
});
const groupDeleteStatusFail = (groupId: string, statusId: string, error: AxiosError) => ({
type: GROUP_DELETE_STATUS_SUCCESS,
groupId,
statusId,
error,
});
const groupKick = (groupId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch(groupKickRequest(groupId, accountId));
@ -677,9 +642,6 @@ export {
GROUP_RELATIONSHIPS_FETCH_REQUEST,
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
GROUP_RELATIONSHIPS_FETCH_FAIL,
GROUP_DELETE_STATUS_REQUEST,
GROUP_DELETE_STATUS_SUCCESS,
GROUP_DELETE_STATUS_FAIL,
GROUP_KICK_REQUEST,
GROUP_KICK_SUCCESS,
GROUP_KICK_FAIL,
@ -735,10 +697,6 @@ export {
fetchGroupRelationshipsRequest,
fetchGroupRelationshipsSuccess,
fetchGroupRelationshipsFail,
groupDeleteStatus,
groupDeleteStatusRequest,
groupDeleteStatusSuccess,
groupDeleteStatusFail,
groupKick,
groupKickRequest,
groupKickSuccess,

@ -74,7 +74,7 @@ const importFetchedGroup = (group: APIEntity) =>
importFetchedGroups([group]);
const importFetchedGroups = (groups: APIEntity[]) => {
const entities = filteredArray(groupSchema).catch([]).parse(groups);
const entities = filteredArray(groupSchema).parse(groups);
return importGroups(entities);
};

@ -142,7 +142,7 @@ const deleteStatusModal = (intl: IntlShape, statusId: string, afterConfirm = ()
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/trash.svg'),
heading: intl.formatMessage(messages.deleteStatusHeading),
message: intl.formatMessage(messages.deleteStatusPrompt, { acct }),
message: intl.formatMessage(messages.deleteStatusPrompt, { acct: <strong className='break-words'>{acct}</strong> }),
confirm: intl.formatMessage(messages.deleteStatusConfirm),
onConfirm: () => {
dispatch(deleteStatus(statusId)).then(() => {

@ -1,7 +1,7 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedGroups, importFetchedStatuses } from './importer';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import type { AxiosError } from 'axios';
import type { SearchFilter } from 'soapbox/reducers/search';
@ -83,10 +83,6 @@ const submitSearch = (filter?: SearchFilter) =>
dispatch(importFetchedStatuses(response.data.statuses));
}
if (response.data.groups) {
dispatch(importFetchedGroups(response.data.groups));
}
dispatch(fetchSearchSuccess(response.data, value, type));
dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {
@ -143,10 +139,6 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: (
dispatch(importFetchedStatuses(data.statuses));
}
if (data.groups) {
dispatch(importFetchedGroups(data.groups));
}
dispatch(expandSearchSuccess(data, value, type));
dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id)));
}).catch(error => {

@ -0,0 +1,20 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import type { Group } from 'soapbox/schemas';
function useDeleteGroupStatus(group: Group, statusId: string) {
const api = useApi();
const { deleteEntity, isSubmitting } = useDeleteEntity(
Entities.STATUSES,
() => api.delete(`/api/v1/groups/${group.id}/statuses/${statusId}`),
);
return {
mutate: deleteEntity,
isSubmitting,
};
}
export { useDeleteGroupStatus };

@ -1,7 +1,10 @@
import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi';
import { statusSchema } from 'soapbox/schemas/status';
import { normalizeStatus } from 'soapbox/normalizers';
import { toSchema } from 'soapbox/utils/normalizers';
const statusSchema = toSchema(normalizeStatus);
function useGroupMedia(groupId: string) {
const api = useApi();

@ -14,7 +14,7 @@ function useGroupMembershipRequests(groupId: string) {
const { entity: relationship } = useGroupRelationship(groupId);
const { entities, invalidate, ...rest } = useEntities(
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
path,
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{
@ -37,6 +37,7 @@ function useGroupMembershipRequests(groupId: string) {
return {
accounts: entities,
refetch: fetchEntities,
authorize,
reject,
...rest,

@ -51,7 +51,7 @@ const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize
await action();
setState(past);
} catch (e) {
console.error(e);
if (e) console.error(e);
}
};
if (typeof countdown === 'number') {

@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => {
key={accountId}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={accountId}
withRelationship={false}
/>
))}
</Widget>

@ -94,7 +94,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
>
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
<span className='truncate'>{item.text}</span>
<span className='truncate font-medium'>{item.text}</span>
{item.count ? (
<span className='ml-auto h-5 w-5 flex-none'>

@ -56,8 +56,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
return (
<Comp
className={clsx({
'flex items-center justify-between px-4 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 dark:from-gradient-start/10 dark:to-gradient-end/10': true,
className={clsx('flex items-center justify-between overflow-hidden bg-gradient-to-r from-gradient-start/20 to-gradient-end/20 px-4 py-2 first:rounded-t-lg last:rounded-b-lg dark:from-gradient-start/10 dark:to-gradient-end/10', {
'cursor-pointer hover:from-gradient-start/30 hover:to-gradient-end/30 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})}
{...linkProps}
@ -71,7 +70,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelec
</div>
{onClick ? (
<HStack space={1} alignItems='center' className='text-gray-700 dark:text-gray-600'>
<HStack space={1} alignItems='center' className='overflow-hidden text-gray-700 dark:text-gray-600'>
{children}
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1 rtl:rotate-180' />

@ -4,14 +4,22 @@ import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { __stub } from 'soapbox/api';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { mockStore, render, screen, rootState } from 'soapbox/jest/test-helpers';
import { type Poll } from 'soapbox/schemas';
import { mockStore, render, screen, rootState } from '../../../jest/test-helpers';
import PollFooter from '../poll-footer';
let poll = normalizePoll({
id: 1,
options: [{ title: 'Apples', votes_count: 0 }],
let poll: Poll = {
id: '1',
options: [{
title: 'Apples',
votes_count: 0,
title_emojified: 'Apples',
}, {
title: 'Oranges',
votes_count: 0,
title_emojified: 'Oranges',
}],
emojis: [],
expired: false,
expires_at: '2020-03-24T19:33:06.000Z',
@ -20,7 +28,7 @@ let poll = normalizePoll({
votes_count: 0,
own_votes: null,
voted: false,
});
};
describe('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
@ -62,10 +70,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has not expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: false,
});
};
});
it('renders time remaining', () => {
@ -77,10 +85,10 @@ describe('<PollFooter />', () => {
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
expired: true,
});
};
});
it('renders closed', () => {
@ -100,10 +108,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: true,
});
};
});
it('renders the Vote button', () => {
@ -115,10 +123,10 @@ describe('<PollFooter />', () => {
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
poll = {
...poll,
multiple: false,
});
};
});
it('does not render the Vote button', () => {

@ -40,21 +40,21 @@ const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX
let votesCount = null;
if (poll.voters_count !== null && poll.voters_count !== undefined) {
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.voters_count }} />;
} else {
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />;
}
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (
{(!showResults && poll.multiple) && (
<Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
)}
<HStack space={1.5} alignItems='center' wrap>
{poll.pleroma.get('non_anonymous') && (
{poll.pleroma?.non_anonymous && (
<>
<Tooltip text={intl.formatMessage(messages.nonAnonymous)}>
<Text theme='muted' weight='medium'>

@ -112,10 +112,13 @@ const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const pollVotesCount = poll.voters_count || poll.votes_count;
const percent = pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
const voted = poll.own_votes?.includes(index);
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
const leading = poll.options
.filter(other => other.title !== option.title)
.every(other => option.votes_count >= other.votes_count);
return (
<div key={option.title}>
{showResults ? (

@ -7,18 +7,20 @@ import { blockAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
import { editEvent } from 'soapbox/actions/events';
import { groupBlock, groupDeleteStatus, groupKick } from 'soapbox/actions/groups';
import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals';
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import { deleteFromTimelines } from 'soapbox/actions/timelines';
import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus';
import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button';
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
import { HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
import { GroupRoles } from 'soapbox/schemas/group-member';
import toast from 'soapbox/toast';
import { isLocal, isRemote } from 'soapbox/utils/accounts';
import copy from 'soapbox/utils/copy';
@ -87,16 +89,7 @@ const messages = defineMessages({
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
replies_disabled_group: { id: 'status.disabled_replies.group_membership', defaultMessage: 'Only group members can reply' },
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
groupModKick: { id: 'status.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
groupModBlock: { id: 'status.group_mod_block', defaultMessage: 'Block @{name} from group' },
deleteFromGroupHeading: { id: 'confirmations.delete_from_group.heading', defaultMessage: 'Delete from group' },
deleteFromGroupMessage: { id: 'confirmations.delete_from_group.message', defaultMessage: 'Are you sure you want to delete @{name}\'s post?' },
kickFromGroupHeading: { id: 'confirmations.kick_from_group.heading', defaultMessage: 'Kick group member' },
kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' },
kickFromGroupConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' },
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Block group member' },
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
blockFromGroupConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
});
interface IStatusActionBar {
@ -121,6 +114,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const features = useFeatures();
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
const { allowedEmoji } = soapboxConfig;
@ -258,8 +252,8 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.id)),
secondary: intl.formatMessage(messages.blockAndReport),
@ -313,31 +307,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }),
message: intl.formatMessage(messages.deleteFromGroupMessage, { name: <strong className='break-words'>{account.username}</strong> }),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)),
}));
};
const handleKickFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account as Account;
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.kickFromGroupHeading),
message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.kickFromGroupConfirm),
onConfirm: () => dispatch(groupKick((status.group as Group).id, account.id)),
}));
};
const handleBlockFromGroup: React.EventHandler<React.MouseEvent> = () => {
const account = status.account as Account;
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.blockFromGroupHeading),
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.blockFromGroupConfirm),
onConfirm: () => dispatch(groupBlock((status.group as Group).id, account.id)),
onConfirm: () => {
deleteGroupStatus.mutate(status.id, {
onSuccess() {
dispatch(deleteFromTimelines(status.id));
},
});
},
}));
};
@ -362,7 +340,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
menu.push({
text: intl.formatMessage(messages.copy),
action: handleCopy,
icon: require('@tabler/icons/link.svg'),
icon: require('@tabler/icons/clipboard-copy.svg'),
});
if (features.embeds && isLocal(account)) {
@ -466,7 +444,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
menu.push({
text: intl.formatMessage(messages.mute, { name: username }),
action: handleMuteClick,
icon: require('@tabler/icons/circle-x.svg'),
icon: require('@tabler/icons/volume-3.svg'),
});
menu.push({
text: intl.formatMessage(messages.block, { name: username }),
@ -480,23 +458,17 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
});
}
if (status.group && groupRelationship?.role && ['admin', 'moderator'].includes(groupRelationship.role)) {
if (status.group &&
groupRelationship?.role &&
[GroupRoles.OWNER].includes(groupRelationship.role) &&
!ownAccount
) {
menu.push(null);
menu.push({
text: intl.formatMessage(messages.groupModDelete),
action: handleDeleteFromGroup,
icon: require('@tabler/icons/trash.svg'),
});
// TODO: figure out when an account is not in the group anymore
menu.push({
text: intl.formatMessage(messages.groupModKick, { name: account.get('username') }),
action: handleKickFromGroup,
icon: require('@tabler/icons/user-minus.svg'),
});
menu.push({
text: intl.formatMessage(messages.groupModBlock, { name: account.get('username') }),
action: handleBlockFromGroup,
icon: require('@tabler/icons/ban.svg'),
destructive: true,
});
}

@ -54,7 +54,7 @@ const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status, hoverable
<Link
key={account.id}
to={`/@${account.acct}`}
className='reply-mentions__account'
className='reply-mentions__account max-w-[200px] truncate align-bottom'
onClick={(e) => e.stopPropagation()}
>
@{isPubkey(account.username) ? account.username.slice(0, 8) : account.username}

@ -21,6 +21,7 @@ import StatusMedia from './status-media';
import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import StatusInfo from './statuses/status-info';
import Tombstone from './tombstone';
import { Card, Icon, Stack, Text } from './ui';
import type {
@ -388,6 +389,17 @@ const Status: React.FC<IStatus> = (props) => {
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
const isSoftDeleted = status.tombstone?.reason === 'deleted';
if (isSoftDeleted) {
return (
<Tombstone
id={status.id}
onMoveUp={(id) => onMoveUp ? onMoveUp(id) : null}
onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null}
/>
);
}
return (
<HotKeys handlers={handlers} data-testid='status'>

@ -19,10 +19,17 @@ const Tombstone: React.FC<ITombstone> = ({ id, onMoveUp, onMoveDown }) => {
return (
<HotKeys handlers={handlers}>
<div className='focusable flex items-center justify-center border border-solid border-gray-200 bg-gray-100 p-9 dark:border-gray-800 dark:bg-gray-900 sm:rounded-xl' tabIndex={0}>
<Text>
<FormattedMessage id='statuses.tombstone' defaultMessage='One or more posts are unavailable.' />
</Text>
<div className='h-16'>
<div
className='focusable flex h-[42px] items-center justify-center rounded-lg border-2 border-gray-200 text-center'
>
<Text theme='muted'>
<FormattedMessage
id='statuses.tombstone'
defaultMessage='One or more posts are unavailable.'
/>
</Text>
</div>
</div>
</HotKeys>
);

@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, IModal>(({
'flex-row-reverse': closePosition === 'left',
})}
>
<h3 className='grow text-lg font-bold leading-6 text-gray-900 dark:text-white'>
<h3 className='grow truncate text-lg font-bold leading-6 text-gray-900 dark:text-white'>
{title}
</h3>

@ -1,24 +0,0 @@
import React, { useCallback } from 'react';
import GroupCard from 'soapbox/components/group-card';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetGroup } from 'soapbox/selectors';
interface IGroupContainer {
id: string
}
const GroupContainer: React.FC<IGroupContainer> = (props) => {
const { id, ...rest } = props;
const getGroup = useCallback(makeGetGroup(), []);
const group = useAppSelector(state => getGroup(state, id));
if (group) {
return <GroupCard group={group} {...rest} />;
} else {
return null;
}
};
export default GroupContainer;

@ -130,7 +130,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/ban.svg'),
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.acct }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.acct}</strong> }} />,
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.id)),
secondary: intl.formatMessage(messages.blockAndReport),
@ -215,7 +215,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
const unfollowModal = getSettings(getState()).get('unfollowModal');
if (unfollowModal) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong>@{account.acct}</strong> }} />,
message: <FormattedMessage id='confirmations.remove_from_followers.message' defaultMessage='Are you sure you want to remove {name} from your followers?' values={{ name: <strong className='break-words'>@{account.acct}</strong> }} />,
confirm: intl.formatMessage(messages.removeFromFollowersConfirm),
onConfirm: () => dispatch(removeFromFollowers(account.id)),
}));

@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities';
/** Map of available provider modules. */
const PROVIDERS: Record<string, () => Promise<AdProvider>> = {
soapbox: async() => (await import(/* webpackChunkName: "features/ads/soapbox" */'./soapbox-config')).default,
rumble: async() => (await import(/* webpackChunkName: "features/ads/rumble" */'./rumble')).default,
truth: async() => (await import(/* webpackChunkName: "features/ads/truth" */'./truth')).default,
};

@ -1,58 +0,0 @@
import axios from 'axios';
import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { normalizeAd, normalizeCard } from 'soapbox/normalizers';
import type { AdProvider } from '.';
/** Rumble ad API entity. */
interface RumbleAd {
type: number
impression: string
click: string
asset: string
expires: number
}
/** Response from Rumble ad server. */
interface RumbleApiResponse {
count: number
ads: RumbleAd[]
}
/** Provides ads from Soapbox Config. */
const RumbleAdProvider: AdProvider = {
getAds: async(getState) => {
const state = getState();
const settings = getSettings(state);
const soapboxConfig = getSoapboxConfig(state);
const endpoint = soapboxConfig.extensions.getIn(['ads', 'endpoint']) as string | undefined;
if (endpoint) {
try {
const { data } = await axios.get<RumbleApiResponse>(endpoint, {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
},
});
return data.ads.map(item => normalizeAd({
impression: item.impression,
card: normalizeCard({
type: item.type === 1 ? 'link' : 'rich',
image: item.asset,
url: item.click,
}),
expires_at: new Date(item.expires * 1000),
}));
} catch (e) {
// do nothing
}
}
return [];
},
};
export default RumbleAdProvider;

@ -1,18 +1,19 @@
import axios from 'axios';
import { z } from 'zod';
import { getSettings } from 'soapbox/actions/settings';
import { normalizeCard } from 'soapbox/normalizers';
import { cardSchema } from 'soapbox/schemas/card';
import { filteredArray } from 'soapbox/schemas/utils';
import type { AdProvider } from '.';
import type { Card } from 'soapbox/types/entities';
/** TruthSocial ad API entity. */
interface TruthAd {
impression: string
card: Card
expires_at: string
reason: string
}
const truthAdSchema = z.object({
impression: z.string(),
card: cardSchema,
expires_at: z.string(),
reason: z.string().catch(''),
});
/** Provides ads from the TruthSocial API. */
const TruthAdProvider: AdProvider = {
@ -21,16 +22,13 @@ const TruthAdProvider: AdProvider = {
const settings = getSettings(state);
try {
const { data } = await axios.get<TruthAd[]>('/api/v2/truth/ads?device=desktop', {
const { data } = await axios.get('/api/v2/truth/ads?device=desktop', {
headers: {
'Accept-Language': settings.get('locale', '*') as string,
'Accept-Language': z.string().catch('*').parse(settings.get('locale')),
},
});
return data.map(item => ({
...item,
card: normalizeCard(item.card),
}));
return filteredArray(truthAdSchema).parse(data);
} catch (e) {
// do nothing
}

@ -57,7 +57,7 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
<FormGroup
labelText={passwordLabel}
hintText={
<Link to='/reset-password' className='hover:underline'>
<Link to='/reset-password' className='hover:underline' tabIndex={-1}>
<FormattedMessage
id='login.reset_password_hint'
defaultMessage='Trouble logging in?'

@ -1,12 +1,10 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
import { render, screen } from '../../../../jest/test-helpers';
import ChatMessageReaction from '../chat-message-reaction';
const emojiReaction = normalizeEmojiReaction({
const emojiReaction = ({
name: '👍',
count: 1,
me: false,
@ -56,7 +54,7 @@ describe('<ChatMessageReaction />', () => {
render(
<ChatMessageReaction
emojiReaction={normalizeEmojiReaction({
emojiReaction={({
name: '👍',
count: 1,
me: true,

@ -312,7 +312,7 @@ const ChatMessage = (props: IChatMessage) => {
</Stack>
</HStack>
{(chatMessage.emoji_reactions?.size) ? (
{(chatMessage.emoji_reactions?.length) ? (
<div
className={clsx({
'space-y-1': true,

@ -2,7 +2,8 @@ import clsx from 'clsx';
import React from 'react';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import { Stack, Text } from 'soapbox/components/ui';
import Markup from 'soapbox/components/markup';
import { Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import { isRtl } from 'soapbox/rtl';
@ -45,8 +46,8 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
hideActions={hideActions}
/>
<Text
className='status__content break-words'
<Markup
className='break-words'
size='sm'
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}

@ -9,13 +9,11 @@ import IconButton from 'soapbox/components/icon-button';
import ScrollableList from 'soapbox/components/scrollable-list';
import { HStack, Tabs, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container';
import GroupContainer from 'soapbox/containers/group-container';
import StatusContainer from 'soapbox/containers/status-container';
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
import PlaceholderGroupCard from 'soapbox/features/placeholder/components/placeholder-group-card';
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso';
@ -24,7 +22,6 @@ import type { SearchFilter } from 'soapbox/reducers/search';
const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
groups: { id: 'search_results.groups', defaultMessage: 'Groups' },
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
});
@ -33,7 +30,6 @@ const SearchResults = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const value = useAppSelector((state) => state.search.submittedValue);
const results = useAppSelector((state) => state.search.results);
@ -66,14 +62,6 @@ const SearchResults = () => {
},
);
if (features.groups) items.push(
{
text: intl.formatMessage(messages.groups),
action: () => selectFilter('groups'),
name: 'groups',
},
);
items.push(
{
text: intl.formatMessage(messages.hashtags),
@ -186,31 +174,6 @@ const SearchResults = () => {
}
}
if (selectedFilter === 'groups') {
hasMore = results.groupsHasMore;
loaded = results.groupsLoaded;
placeholderComponent = PlaceholderGroupCard;
if (results.groups && results.groups.size > 0) {
searchResults = results.groups.map((groupId: string) => (
<GroupContainer id={groupId} />
));
resultsIds = results.groups;
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
searchResults = null;
} else if (loaded) {
noResultsMessage = (
<div className='empty-column-indicator'>
<FormattedMessage
id='empty_column.search.groups'
defaultMessage='There are no groups results for "{term}"'
values={{ term: value }}
/>
</div>
);
}
}
if (selectedFilter === 'hashtags') {
hasMore = results.hashtagsHasMore;
loaded = results.hashtagsLoaded;
@ -238,11 +201,11 @@ const SearchResults = () => {
{filterByAccount ? (
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={require('@tabler/icons/x.svg')} onClick={handleUnsetAccount} />
<Text>
<Text truncate>
<FormattedMessage
id='search_results.filter_message'
defaultMessage='You are searching for posts from @{acct}.'
values={{ acct: account }}
values={{ acct: <strong className='break-words'>{account}</strong> }}
/>
</Text>
</HStack>

@ -1,6 +1,6 @@
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -135,6 +135,18 @@ const Search = (props: ISearch) => {
componentProps.autoSelect = false;
}
useEffect(() => {
return () => {
const newPath = history.location.pathname;
const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g)
|| !!newPath.match(/\/tags\/.+/g);
if (!shouldPersistSearch) {
dispatch(changeSearch(''));
}
};
}, []);
return (
<div className='w-full'>
<label htmlFor='search' className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>

@ -124,7 +124,7 @@ const accountToCredentials = (account: Account): AccountCredentials => {
discoverable: account.discoverable,
bot: account.bot,
display_name: account.display_name,
note: account.source.get('note'),
note: account.source.get('note', ''),
locked: account.locked,
fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', ImmutableList()).toJS()],
stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true,

@ -207,7 +207,7 @@ const FeedCarousel = () => {
style={{ width: widthPerAvatar || 'auto' }}
key={idx}
>
<PlaceholderAvatar size={56} withText />
<PlaceholderAvatar size={56} withText className='py-3' />
</div>
))
) : (

@ -2,6 +2,7 @@ import React from 'react';
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import { Group } from 'soapbox/types/entities';
import GroupActionButton from '../group-action-button';
@ -45,7 +46,7 @@ describe('<GroupActionButton />', () => {
beforeEach(() => {
group = buildGroup({
relationship: buildGroupRelationship({
member: null,
member: false,
}),
});
});
@ -98,7 +99,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({
requested: false,
member: true,
role: 'owner',
role: GroupRoles.OWNER,
}),
});
});
@ -116,7 +117,7 @@ describe('<GroupActionButton />', () => {
relationship: buildGroupRelationship({
requested: false,
member: true,
role: 'user',
role: GroupRoles.USER,
}),
});
});

@ -0,0 +1,46 @@
import React from 'react';
import { buildGroup } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { Group } from 'soapbox/types/entities';
import GroupHeader from '../group-header';
let group: Group;
describe('<GroupHeader />', () => {
describe('without a group', () => {
it('should render the blankslate', () => {
render(<GroupHeader group={null} />);
expect(screen.getByTestId('group-header-missing')).toBeInTheDocument();
});
});
describe('when the Group has been deleted', () => {
it('only shows name, header, and avatar', () => {
group = buildGroup({ display_name: 'my group', deleted_at: new Date().toISOString() });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.queryAllByTestId('group-actions')).toHaveLength(0);
expect(screen.queryAllByTestId('group-meta')).toHaveLength(0);
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
describe('with a valid Group', () => {
it('only shows all fields', () => {
group = buildGroup({ display_name: 'my group', deleted_at: null });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.getByTestId('group-actions')).toBeInTheDocument();
expect(screen.getByTestId('group-meta')).toBeInTheDocument();
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
});

@ -0,0 +1,320 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { __stub } from 'soapbox/api';
import { buildGroup, buildGroupMember, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupMemberListItem from '../group-member-list-item';
describe('<GroupMemberListItem />', () => {
describe('account rendering', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the users avatar', async () => {
const group = buildGroup({
relationship: buildGroupRelationship(),
});
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('group-member-list-item')).toHaveTextContent(groupMember.account.display_name);
});
});
});
describe('role badge', () => {
const accountId = '4';
const group = buildGroup();
describe('when the user is an Owner', () => {
const groupMember = buildGroupMember({ role: GroupRoles.OWNER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('owner');
});
});
});
describe('when the user is an Admin', () => {
const groupMember = buildGroupMember({ role: GroupRoles.ADMIN }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('admin');
});
});
});
describe('when the user is an User', () => {
const groupMember = buildGroupMember({ role: GroupRoles.USER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render no correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.queryAllByTestId('role-badge')).toHaveLength(0);
});
});
});
});
describe('as a Group owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
describe('when "canPromoteToAdmin is true', () => {
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when "canPromoteToAdmin is false', () => {
it('should prevent promoting user to Admin', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin={false} />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
await user.click(screen.getByTitle('Assign admin role'));
});
expect(screen.getByTestId('toast')).toHaveTextContent('Admin limit reached');
});
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Remove admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
});
describe('as a Group admin', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Admin actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).not.toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
describe('when the user has role of "owner"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.OWNER,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});
describe('as a Group user', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.USER,
member: true,
}),
});
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});

@ -17,7 +17,7 @@ describe('<GroupOptionsButton />', () => {
requested: false,
member: true,
blocked_by: true,
role: 'user',
role: GroupRoles.USER,
}),
});
});

@ -0,0 +1,123 @@
import React from 'react';
import { buildGroup, buildGroupTag, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupTagListItem from '../group-tag-list-item';
describe('<GroupTagListItem />', () => {
describe('tag name', () => {
const name = 'hello';
it('should render the tag name', () => {
const group = buildGroup();
const tag = buildGroupTag({ name });
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-list-item')).toHaveTextContent(`#${name}`);
});
describe('when the tag is "visible"', () => {
const group = buildGroup();
const tag = buildGroupTag({ name, visible: true });
it('renders the default name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
describe('when the tag is not "visible" and user is Owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-400');
});
});
describe('when the tag is not "visible" and user is Admin or User', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
});
describe('pinning', () => {
describe('as an owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('renders the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('pin-icon')).toBeInTheDocument();
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('as a non-owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
});
});
});
});

@ -34,7 +34,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
if (!group) {
return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'>
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'>
<div>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
</div>
@ -107,7 +107,10 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
}
return (
<div className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'>
<div
data-testid='group-header-image'
className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'
>
{isHeaderMissing ? (
<Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' />
) : header}
@ -120,7 +123,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<div className='relative'>
{renderHeader()}
<div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2'>
<div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2' data-testid='group-avatar'>
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
<GroupAvatar
group={group}
@ -136,11 +139,12 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
size='xl'
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
data-testid='group-name'
/>
{!isDeleted && (
<>
<Stack space={1} alignItems='center'>
<Stack data-testid='group-meta' space={1} alignItems='center'>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupRelationship group={group} />
<GroupPrivacy group={group} />
@ -154,7 +158,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
/>
</Stack>
<HStack alignItems='center' space={2}>
<HStack alignItems='center' space={2} data-testid='group-actions'>
<GroupOptionsButton group={group} />
<GroupActionButton group={group} />
</HStack>

@ -180,7 +180,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
}
return (
<HStack alignItems='center' justifyContent='between'>
<HStack
alignItems='center'
justifyContent='between'
data-testid='group-member-list-item'
>
<div className='w-full'>
<Account account={member.account} withRelationship={false} />
</div>
@ -188,6 +192,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
<HStack alignItems='center' space={2}>
{(isMemberOwner || isMemberAdmin) ? (
<span
data-testid='role-badge'
className={
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
'bg-primary-200 text-primary-500': isMemberOwner,

@ -102,6 +102,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
require('@tabler/icons/pin.svg')
}
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
data-testid='pin-icon'
/>
</Tooltip>
);
@ -123,13 +124,18 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
};
return (
<HStack alignItems='center' justifyContent='between'>
<HStack
alignItems='center'
justifyContent='between'
data-testid='group-tag-list-item'
>
<Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'>
<Stack>
<Text
weight='bold'
theme={(tag.visible || !isOwner) ? 'default' : 'subtle'}
className='group-hover:underline'
data-testid='group-tag-name'
>
#{tag.name}
</Text>
@ -137,7 +143,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
{intl.formatMessage(messages.total)}:
{' '}
<Text size='sm' theme='inherit' weight='semibold' tag='span'>
{shortNumberFormat(tag.groups)}
{shortNumberFormat(tag.uses)}
</Text>
</Text>
</Stack>

@ -1,3 +1,4 @@
import { AxiosError } from 'axios';
import React, { useEffect } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
@ -58,7 +59,7 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
const { group } = useGroup(id);
const { accounts, authorize, reject, isLoading } = useGroupMembershipRequests(id);
const { accounts, authorize, reject, refetch, isLoading } = useGroupMembershipRequests(id);
const { invalidate } = useGroupMembers(id, GroupRoles.USER);
useEffect(() => {
@ -80,19 +81,35 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
}
async function handleAuthorize(account: AccountEntity) {
try {
await authorize(account.id);
} catch (_e) {
toast.error(intl.formatMessage(messages.authorizeFail, { name: account.username }));
}
return authorize(account.id)
.then(() => Promise.resolve())
.catch((error: AxiosError) => {
refetch();
let message = intl.formatMessage(messages.authorizeFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error;
}
toast.error(message);
return Promise.reject();
});
}
async function handleReject(account: AccountEntity) {
try {
await reject(account.id);
} catch (_e) {
toast.error(intl.formatMessage(messages.rejectFail, { name: account.username }));
}
return reject(account.id)
.then(() => Promise.resolve())
.catch((error: AxiosError) => {
refetch();
let message = intl.formatMessage(messages.rejectFail, { name: account.username });
if (error.response?.status === 409) {
message = (error.response?.data as any).error;
}
toast.error(message);
return Promise.reject();
});
}
return (

@ -16,16 +16,16 @@ import ColumnForbidden from '../ui/components/column-forbidden';
type RouteParams = { groupId: string };
const messages = defineMessages({
heading: { id: 'column.manage_group', defaultMessage: 'Manage group' },
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit group' },
heading: { id: 'column.manage_group', defaultMessage: 'Manage Group' },
editGroup: { id: 'manage_group.edit_group', defaultMessage: 'Edit Group' },
pendingRequests: { id: 'manage_group.pending_requests', defaultMessage: 'Pending Requests' },
blockedMembers: { id: 'manage_group.blocked_members', defaultMessage: 'Banned Members' },
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete group' },
deleteGroup: { id: 'manage_group.delete_group', defaultMessage: 'Delete Group' },
deleteConfirm: { id: 'confirmations.delete_group.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete group' },
deleteHeading: { id: 'confirmations.delete_group.heading', defaultMessage: 'Delete Group' },
deleteMessage: { id: 'confirmations.delete_group.message', defaultMessage: 'Are you sure you want to delete this group? This is a permanent action that cannot be undone.' },
members: { id: 'group.tabs.members', defaultMessage: 'Members' },
other: { id: 'settings.other', defaultMessage: 'Other options' },
other: { id: 'settings.other', defaultMessage: 'Other Options' },
deleteSuccess: { id: 'group.delete.success', defaultMessage: 'Group successfully deleted' },
});

@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { Stack } from 'soapbox/components/ui';
@ -5,10 +6,11 @@ import { Stack } from 'soapbox/components/ui';
interface IPlaceholderAvatar {
size: number
withText?: boolean
className?: string
}
/** Fake avatar to display while data is loading. */
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = false }) => {
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = false, className }) => {
const style = React.useMemo(() => {
if (!size) {
return {};
@ -21,7 +23,10 @@ const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = fals
}, [size]);
return (
<Stack space={2} className='animate-pulse py-3 text-center'>
<Stack
space={2}
className={clsx('animate-pulse text-center', className)}
>
<div
className='mx-auto block rounded-full bg-primary-50 dark:bg-primary-800'
style={style}

@ -8,11 +8,11 @@ import PlaceholderDisplayName from './placeholder-display-name';
import PlaceholderStatusContent from './placeholder-status-content';
interface IPlaceholderStatus {
variant?: 'rounded' | 'slim'
variant?: 'rounded' | 'slim' | 'default'
}
/** Fake status to display while data is loading. */
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ variant = 'rounded' }) => (
const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ variant }) => (
<div
className={clsx({
'status-placeholder bg-white dark:bg-primary-900': true,

@ -74,7 +74,7 @@ const Settings = () => {
<CardBody>
<List>
<ListItem label={intl.formatMessage(messages.editProfile)} onClick={navigateToEditProfile}>
<span>{displayName}</span>
<span className='max-w-full truncate'>{displayName}</span>
</ListItem>
</List>
</CardBody>

@ -125,7 +125,6 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
<Account
key={account.id}
account={account}
timestamp={actualStatus.created_at}
avatarSize={42}
hideActions
approvalStatus={actualStatus.approval_status}

@ -230,7 +230,7 @@ const InteractionCounter: React.FC<IInteractionCounter> = ({ count, onClick, chi
}
>
<HStack space={1} alignItems='center'>
<Text theme='primary' weight='bold'>
<Text weight='bold'>
{shortNumberFormat(count)}
</Text>

@ -31,9 +31,8 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
return (
<div
className={clsx('thread__connector', {
'thread__connector--top': isConnectedTop,
'thread__connector--bottom': isConnectedBottom,
className={clsx('absolute left-5 z-[1] hidden w-0.5 bg-gray-200 rtl:left-auto rtl:right-5 dark:bg-primary-800', {
'!block top-[calc(12px+42px)] h-[calc(100%-42px-8px-1rem)]': isConnectedBottom,
})}
/>
);
@ -46,7 +45,7 @@ const ThreadStatus: React.FC<IThreadStatus> = (props): JSX.Element => {
// @ts-ignore FIXME
<StatusContainer {...props} showGroup={false} />
) : (
<PlaceholderStatus variant='slim' />
<PlaceholderStatus variant='default' />
)}
</div>
);

@ -404,7 +404,7 @@ const Thread: React.FC<IThread> = (props) => {
useEffect(() => {
scroller.current?.scrollToIndex({
index: ancestorsIds.size,
offset: -140,
offset: -146,
});
setImmediate(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus());
@ -443,7 +443,9 @@ const Thread: React.FC<IThread> = (props) => {
);
} else if (!status) {
return (
<PlaceholderStatus />
<Column>
<PlaceholderStatus />
</Column>
);
}

@ -1,8 +1,9 @@
// import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount, normalizeRelationship } from '../../../../normalizers';
import { buildRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { normalizeAccount } from 'soapbox/normalizers';
import SubscribeButton from '../subscription-button';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
@ -19,162 +20,10 @@ describe('<SubscribeButton />', () => {
describe('with "accountNotifies" disabled', () => {
it('renders nothing', () => {
const account = normalizeAccount({ ...justin, relationship: normalizeRelationship({ following: true }) }) as ReducerAccount;
const account = normalizeAccount({ ...justin, relationship: buildRelationship({ following: true }) }) as ReducerAccount;
render(<SubscribeButton account={account} />, undefined, store);
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
// describe('with "accountNotifies" enabled', () => {
// beforeEach(() => {
// store = {
// ...store,
// instance: normalizeInstance({
// version: '3.4.1 (compatible; TruthSocial 1.0.0)',
// software: 'TRUTHSOCIAL',
// pleroma: ImmutableMap({}),
// }),
// };
// });
// describe('when the relationship is requested', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ requested: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// describe('when the user is not following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: false }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders nothing', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
// });
// });
// describe('when the user is following the account', () => {
// beforeEach(() => {
// account = normalizeAccount({ ...account, relationship: normalizeRelationship({ following: true }) });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button')).toBeInTheDocument();
// });
// describe('when the user "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: true }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Unsubscribe to notifications from @${account.acct}`);
// });
// });
// describe('when the user is not "isSubscribed"', () => {
// beforeEach(() => {
// account = normalizeAccount({
// ...account,
// relationship: normalizeRelationship({ requested: true, notifying: false }),
// });
// store = {
// ...store,
// accounts: ImmutableMap({
// '1': account,
// }),
// };
// });
// it('renders the unsubscribe button', () => {
// render(<SubscribeButton account={account} />, null, store);
// expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`);
// });
// });
// });
// });
});

@ -40,7 +40,7 @@ const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
useEffect(() => {
setLoading(true);
if (group && (isMember || !isPrivate)) {
if (group && !group.deleted_at && (isMember || !isPrivate)) {
dispatch(expandGroupMediaTimeline(group.id))
// @ts-ignore
.then(() => setLoading(false))
@ -72,7 +72,7 @@ const GroupMediaPanel: React.FC<IGroupMediaPanel> = ({ group }) => {
}
};
if (isPrivate && !isMember) {
if ((isPrivate && !isMember) || group?.deleted_at) {
return null;
}

@ -19,7 +19,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
const intl = useIntl();
const handleCopyLink = () => {
copy(`${window.location.origin}/group/${group?.slug}`, () => {
copy(group?.url as string, () => {
toast.success(intl.formatMessage(messages.copied));
});
};

@ -60,7 +60,7 @@ const MuteModal = () => {
<FormattedMessage
id='confirmations.mute.message'
defaultMessage='Are you sure you want to mute {name}?'
values={{ name: <strong>@{account.acct}</strong> }}
values={{ name: <strong className='break-words'>@{account.acct}</strong> }}
/>
</Text>

@ -120,7 +120,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='sm' theme='muted' direction='ltr'>
<Text size='sm' theme='muted' direction='ltr' truncate>
@{username}
</Text>
</HStack>

@ -59,7 +59,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
<Stack>
<Link to={`/@${account.acct}`}>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} truncate />
{verified && <VerificationBadge />}
@ -71,7 +71,7 @@ const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain })
</HStack>
</Link>
<Text size='sm' theme='muted'>
<Text size='sm' theme='muted' truncate>
@{getAcct(account, fqn)}
</Text>
</Stack>

@ -1,33 +1,88 @@
import { v4 as uuidv4 } from 'uuid';
import {
groupSchema,
accountSchema,
adSchema,
cardSchema,
groupMemberSchema,
groupRelationshipSchema,
groupSchema,
groupTagSchema,
relationshipSchema,
type Account,
type Ad,
type Card,
type Group,
type GroupMember,
type GroupRelationship,
type GroupTag,
type Relationship,
} from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
// 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 {
function buildAccount(props: Partial<Account> = {}): Account {
return accountSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildCard(props: Partial<Card> = {}): Card {
return cardSchema.parse(Object.assign({
url: 'https://soapbox.test',
}, props));
}
function buildGroup(props: Partial<Group> = {}): Group {
return groupSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildGroupRelationship(props: Record<string, any> = {}): GroupRelationship {
function buildGroupRelationship(props: Partial<GroupRelationship> = {}): GroupRelationship {
return groupRelationshipSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildGroupTag(props: Record<string, any> = {}): GroupTag {
function buildGroupTag(props: Partial<GroupTag> = {}): GroupTag {
return groupTagSchema.parse(Object.assign({
id: uuidv4(),
name: uuidv4(),
}, props));
}
function buildGroupMember(
props: Partial<GroupMember> = {},
accountProps: Partial<Account> = {},
): GroupMember {
return groupMemberSchema.parse(Object.assign({
id: uuidv4(),
account: buildAccount(accountProps),
role: GroupRoles.USER,
}, props));
}
function buildAd(props: Partial<Ad> = {}): Ad {
return adSchema.parse(Object.assign({
card: buildCard(),
}, props));
}
function buildRelationship(props: Partial<Relationship> = {}): Relationship {
return relationshipSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
export { buildGroup, buildGroupRelationship, buildGroupTag };
export {
buildAd,
buildCard,
buildGroup,
buildGroupMember,
buildGroupRelationship,
buildGroupTag,
buildRelationship,
};

@ -1515,7 +1515,6 @@
"trendsPanel.viewAll": "إظهار الكل",
"unauthorized_modal.text": "يجب عليك تسجيل الدخول لتتمكن من القيام بذلك.",
"unauthorized_modal.title": "التسجيل في {site_title}",
"upload_area.title": "اسحب ملف وافلته لتحميله",
"upload_button.label": "إرفاق وسائط (JPEG، PNG، GIF، WebM، MP4، MOV)",
"upload_error.image_size_limit": "الصورة تجاوزت الحجم المسموح به: ({limt})",
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",

@ -420,7 +420,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "টেনে এখানে ছেড়ে দিলে এখানে যুক্ত করা যাবে",
"upload_button.label": "ছবি বা ভিডিও যুক্ত করতে (এসব ধরণের: JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "যা যুক্ত করতে চাচ্ছেন সেটি বেশি বড়, এখানকার সর্বাধিকের মেমোরির উপরে চলে গেছে।",
"upload_error.poll": "নির্বাচনক্ষেত্রে কোনো ফাইল যুক্ত করা যাবেনা।",

@ -599,7 +599,6 @@
"trends.title": "Tendències",
"unauthorized_modal.text": "Heu d'iniciar sessió per fer això.",
"unauthorized_modal.title": "Registrar-se a {site_title}",
"upload_area.title": "Arrossega i deixa anar per a carregar",
"upload_button.label": "Afegir multimèdia (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "S'ha superat el límit de càrrega d'arxius.",
"upload_error.poll": "No es permet l'enviament de fitxers en les enquestes.",

@ -420,7 +420,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop per caricà un fugliale",
"upload_button.label": "Aghjunghje un media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Limita di caricamentu di fugliali trapassata.",
"upload_error.poll": "Ùn si pò micca caricà fugliali cù i scandagli.",

@ -742,7 +742,6 @@
"trends.title": "Trendy",
"unauthorized_modal.text": "Nejprve se přihlašte.",
"unauthorized_modal.title": "Registrovat se na {site_title}",
"upload_area.title": "Přetažením nahrajete",
"upload_button.label": "Přidat média (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Byl překročen limit nahraných souborů.",
"upload_error.poll": "Nahrávání souborů není povoleno u anket.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Llusgwch & gollwing i uwchlwytho",
"upload_button.label": "Ychwanegwch gyfryngau (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Wedi mynd heibio'r uchafswm terfyn uwchlwytho.",

@ -418,7 +418,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Træk og slip for at uploade",
"upload_button.label": "Tilføj medie (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Uploadgrænse overskredet.",
"upload_error.poll": "Filupload ikke tilladt sammen med afstemninger.",

@ -1390,7 +1390,6 @@
"trendsPanel.viewAll": "Alle anzeigen",
"unauthorized_modal.text": "Für diese Aktion musst Du angemeldet sein.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Zum Hochladen hereinziehen",
"upload_button.label": "Mediendatei hinzufügen (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Bild überschreitet das Limit von ({limit})",
"upload_error.limit": "Dateiupload-Limit erreicht.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop για να ανεβάσεις",
"upload_button.label": "Πρόσθεσε πολυμέσα (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Υπέρβαση ορίου μεγέθους ανεβασμένων αρχείων.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "𐑿 𐑯𐑰𐑛 𐑑 𐑚𐑰 𐑤𐑪𐑜𐑛 𐑦𐑯 𐑑 𐑛𐑵 𐑞𐑨𐑑.",
"unauthorized_modal.title": "𐑕𐑲𐑯 𐑳𐑐 𐑓 {site_title}",
"upload_area.title": "𐑛𐑮𐑨𐑜 𐑯 𐑛𐑮𐑪𐑐 𐑑 𐑳𐑐𐑤𐑴𐑛",
"upload_button.label": "𐑨𐑛 𐑥𐑰𐑛𐑾 𐑩𐑑𐑨𐑗𐑥𐑩𐑯𐑑",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "𐑓𐑲𐑤 𐑳𐑐𐑤𐑴𐑛 𐑤𐑦𐑥𐑦𐑑 𐑦𐑒𐑕𐑰𐑛𐑩𐑛.",

@ -358,7 +358,7 @@
"column.import_data": "Import data",
"column.info": "Server information",
"column.lists": "Lists",
"column.manage_group": "Manage group",
"column.manage_group": "Manage Group",
"column.mentions": "Mentions",
"column.mfa": "Multi-Factor Authentication",
"column.mfa_cancel": "Cancel",
@ -488,10 +488,9 @@
"confirmations.delete_event.confirm": "Delete",
"confirmations.delete_event.heading": "Delete event",
"confirmations.delete_event.message": "Are you sure you want to delete this event?",
"confirmations.delete_from_group.heading": "Delete from group",
"confirmations.delete_from_group.message": "Are you sure you want to delete @{name}'s post?",
"confirmations.delete_group.confirm": "Delete",
"confirmations.delete_group.heading": "Delete group",
"confirmations.delete_group.heading": "Delete Group",
"confirmations.delete_group.message": "Are you sure you want to delete this group? This is a permanent action that cannot be undone.",
"confirmations.delete_list.confirm": "Delete",
"confirmations.delete_list.heading": "Delete list",
@ -500,7 +499,6 @@
"confirmations.domain_block.heading": "Block {domain}",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications.",
"confirmations.kick_from_group.confirm": "Kick",
"confirmations.kick_from_group.heading": "Kick group member",
"confirmations.kick_from_group.message": "Are you sure you want to kick @{name} from this group?",
"confirmations.leave_event.confirm": "Leave event",
"confirmations.leave_event.message": "If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?",
@ -692,7 +690,6 @@
"empty_column.remote": "There is nothing here! Manually follow users from {instance} to fill it up.",
"empty_column.scheduled_statuses": "You don't have any scheduled statuses yet. When you add one, it will show up here.",
"empty_column.search.accounts": "There are no people results for \"{term}\"",
"empty_column.search.groups": "There are no groups results for \"{term}\"",
"empty_column.search.hashtags": "There are no hashtags results for \"{term}\"",
"empty_column.search.statuses": "There are no posts results for \"{term}\"",
"empty_column.test": "The test timeline is empty.",
@ -972,9 +969,9 @@
"manage_group.confirmation.share": "Share this group",
"manage_group.confirmation.title": "Youre all set!",
"manage_group.create": "Create Group",
"manage_group.delete_group": "Delete group",
"manage_group.delete_group": "Delete Group",
"manage_group.done": "Done",
"manage_group.edit_group": "Edit group",
"manage_group.edit_group": "Edit Group",
"manage_group.fields.cannot_change_hint": "This cannot be changed after the group is created.",
"manage_group.fields.description_label": "Description",
"manage_group.fields.description_placeholder": "Description",
@ -1324,7 +1321,6 @@
"search.placeholder": "Search",
"search_results.accounts": "People",
"search_results.filter_message": "You are searching for posts from @{acct}.",
"search_results.groups": "Groups",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Posts",
"security.codes.fail": "Failed to fetch backup codes",
@ -1356,7 +1352,7 @@
"settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile",
"settings.messages.label": "Allow users to start a new chat with you",
"settings.other": "Other options",
"settings.other": "Other Options",
"settings.preferences": "Preferences",
"settings.profile": "Profile",
"settings.save.success": "Your preferences have been saved!",
@ -1442,7 +1438,7 @@
"status.cancel_reblog_private": "Un-repost",
"status.cannot_reblog": "This post cannot be reposted",
"status.chat": "Chat with @{name}",
"status.copy": "Copy link to post",
"status.copy": "Copy Link to Post",
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
@ -1454,9 +1450,7 @@
"status.favourite": "Like",
"status.filtered": "Filtered",
"status.group": "Posted in {group}",
"status.group_mod_block": "Block @{name} from group",
"status.group_mod_delete": "Delete post from group",
"status.group_mod_kick": "Kick @{name} from group",
"status.interactions.dislikes": "{count, plural, one {Dislike} other {Dislikes}}",
"status.interactions.favourites": "{count, plural, one {Like} other {Likes}}",
"status.interactions.quotes": "{count, plural, one {Quote} other {Quotes}}",
@ -1464,8 +1458,8 @@
"status.load_more": "Load more",
"status.mention": "Mention @{name}",
"status.more": "More",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this post",
"status.mute_conversation": "Mute Conversation",
"status.open": "Show Post Details",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.quote": "Quote post",
@ -1501,7 +1495,7 @@
"status.translated_from_with": "Translated from {lang} using {provider}",
"status.unbookmark": "Remove bookmark",
"status.unbookmarked": "Bookmark removed.",
"status.unmute_conversation": "Unmute conversation",
"status.unmute_conversation": "Unmute Conversation",
"status.unpin": "Unpin from profile",
"status_list.queue_label": "Click to see {count} new {count, plural, one {post} other {posts}}",
"statuses.quote_tombstone": "Post is unavailable.",

@ -421,7 +421,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persono} other {personoj}} parolas",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Altreni kaj lasi por alŝuti",
"upload_button.label": "Aldoni aŭdovidaĵon (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Limo de dosiera alŝutado transpasita.",
"upload_error.poll": "Alŝuto de dosiero ne permesita kun balotenketo.",

@ -1180,7 +1180,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Para subir, arrastrá y soltá",
"upload_button.label": "Agregar medios",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Se excedió el límite de subida de archivos.",

@ -1492,7 +1492,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Arrastra y suelta para subir",
"upload_button.label": "Subir multimedia (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Límite de subida de archivos excedido.",

@ -421,7 +421,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {inimene} other {inimesed}} talking",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Lohista & aseta üleslaadimiseks",
"upload_button.label": "Lisa meedia (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Faili üleslaadimise limiit ületatud.",
"upload_error.poll": "Küsitlustes pole faili üleslaadimine lubatud.",

@ -421,7 +421,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} hitz egiten",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Arrastatu eta jaregin igotzeko",
"upload_button.label": "Gehitu multimedia (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Fitxategi igoera muga gaindituta.",
"upload_error.poll": "Ez da inkestetan fitxategiak igotzea onartzen.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "برای بارگذاری به این‌جا بکشید",
"upload_button.label": "افزودن عکس و ویدیو (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "از حد مجاز باگذاری فراتر رفتید.",

@ -417,7 +417,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {henkilö} other {henkilöä}} keskustelee",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Lataa raahaamalla ja pudottamalla tähän",
"upload_button.label": "Lisää mediaa",
"upload_error.limit": "Tiedostolatauksien raja ylitetty.",
"upload_error.poll": "Tiedon lataaminen ei ole sallittua kyselyissä.",

@ -1309,7 +1309,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Glissez et déposez pour envoyer",
"upload_button.label": "Joindre un média (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Taille maximale d'envoi de fichier dépassée.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -426,7 +426,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} outras {people}} conversando",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Arrastre e solte para subir",
"upload_button.label": "Engadir medios (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Excedeu o límite de subida de ficheiros.",
"upload_error.poll": "Non se poden subir ficheiros nas sondaxes.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "אתה צריך להיות מחובר כדי לעשות זאת.",
"unauthorized_modal.title": "להירשם ל{site_title}",
"upload_area.title": "ניתן להעלות על ידי Drag & drop",
"upload_button.label": "הוספת מדיה",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "חרגת ממגבלת העלאת הקבצים.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1305,7 +1305,6 @@
"trendsPanel.viewAll": "Prikaži još",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Povuci i spusti kako bi uploadao",
"upload_button.label": "Dodaj media",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Húzd ide a feltöltéshez",
"upload_button.label": "Média hozzáadása (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Túllépted a fájl feltöltési limitet.",

@ -345,7 +345,6 @@
"thread_login.signup": "Sign up",
"toast.view": "View",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Քաշիր ու նետիր՝ վերբեռնելու համար",
"upload_button.label": "Ավելացնել մեդիա",
"upload_form.description": "Նկարագրություն ավելացրու տեսողական խնդիրներ ունեցողների համար",
"upload_form.undo": "Հետարկել",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Seret & lepaskan untuk mengunggah",
"upload_button.label": "Tambahkan media",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Tranar faligar por kargar",
"upload_button.label": "Adjuntar kontenajo",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "Þú þarft að vera skráður inn til að gera þetta.",
"unauthorized_modal.title": "Nýskrá á {site_title}",
"upload_area.title": "Dragðu-og-slepptu hér til að senda inn",
"upload_button.label": "Bæta við viðhengi",
"upload_error.image_size_limit": "Mynd fer yfir núverandi skráarstærðarmörk ({limit})",
"upload_error.limit": "Fór yfir takmörk á innsendingum skráa.",

@ -1440,7 +1440,6 @@
"trendsPanel.viewAll": "Di più",
"unauthorized_modal.text": "Per fare questo, devi prima autenticarti.",
"unauthorized_modal.title": "Iscriviti su {site_title}",
"upload_area.title": "Trascina per caricare",
"upload_button.label": "Aggiungi allegati",
"upload_error.image_size_limit": "L'immagine eccede il limite di dimensioni ({limit})",
"upload_error.limit": "Hai superato il limite di quanti file puoi caricare.",

@ -1188,7 +1188,6 @@
"trendsPanel.viewAll": "すべて表示",
"unauthorized_modal.text": "ログインする必要があります。",
"unauthorized_modal.title": "{site_title}へ新規登録",
"upload_area.title": "ドラッグ&ドロップでアップロード",
"upload_button.label": "メディアを追加 (JPG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "画像が現在のファイルサイズ制限({limit})を越えています",
"upload_error.limit": "アップロードできる上限を超えています。",

@ -377,7 +377,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} საუბრობს",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "გადმოწიეთ და ჩააგდეთ ასატვირთათ",
"upload_button.label": "მედიის დამატება",
"upload_error.video_duration_limit": "Video exceeds the current duration limit ({limit} seconds)",
"upload_form.description": "აღწერილობა ვიზუალურად უფასურისთვის",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Жүктеу үшін сүйреп әкеліңіз",
"upload_button.label": "Медиа қосу (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "Файл жүктеу лимитінен асып кеттіңіз.",

@ -1,17 +1,17 @@
{
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"about.also_available": "가능:",
"accordion.collapse": "접기",
"accordion.expand": "펼치기기",
"account.add_or_remove_from_list": "리스트에 추가 혹은 삭제",
"account.badges.bot": "봇",
"account.birthday": "Born {date}",
"account.birthday_today": "Birthday is today!",
"account.birthday": "생일 {date}",
"account.birthday_today": "오늘이 생일입니다!",
"account.block": "@{name}을 차단",
"account.block_domain": "{domain} 전체를 숨김",
"account.blocked": "차단 됨",
"account.chat": "Chat with @{name}",
"account.deactivated": "Deactivated",
"account.direct": "@{name}으로부터의 다이렉트 메시지",
"account.deactivated": "비활성화됨됨",
"account.direct": "@{name}으로부터의 다이렉트 메시지(DM)",
"account.domain_blocked": "Domain hidden",
"account.edit_profile": "프로필 편집",
"account.endorse": "프로필에 나타내기",
@ -848,7 +848,7 @@
"registration.header": "Register your account",
"registration.newsletter": "Subscribe to newsletter.",
"registration.password_mismatch": "Passwords don't match.",
"registration.privacy": "Privacy Policy",
"registration.privacy": "개인정보처리방",
"registration.reason": "Why do you want to join?",
"registration.reason_hint": "This will help us review your application",
"registration.sign_up": "Sign up",
@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "드래그 & 드롭으로 업로드",
"upload_button.label": "미디어 추가 (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "파일 업로드 제한에 도달했습니다.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

@ -416,7 +416,6 @@
"toast.view": "View",
"trends.count_by_accounts": "{count} {rawCount, plural, one {persoon praat} other {mensen praten}} hierover",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Hiernaar toe slepen om te uploaden",
"upload_button.label": "Media toevoegen (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Uploadlimiet van bestand overschreden.",
"upload_error.poll": "Het uploaden van bestanden is in polls niet toegestaan.",

@ -1130,7 +1130,6 @@
"trendsPanel.viewAll": "View all",
"unauthorized_modal.text": "You need to be logged in to do that.",
"unauthorized_modal.title": "Sign up for {site_title}",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})",
"upload_error.limit": "File upload limit exceeded.",

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save