Merge remote-tracking branch 'soapbox/develop' into follow-hashtags
This commit is contained in:
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": "You’re 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…
Reference in New Issue