diff --git a/CHANGELOG.md b/CHANGELOG.md index 179bee42b..56d312320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/soapbox/actions/__tests__/account-notes.test.ts b/app/soapbox/actions/__tests__/account-notes.test.ts index 8b85eecc5..a00a9d877 100644 --- a/app/soapbox/actions/__tests__/account-notes.test.ts +++ b/app/soapbox/actions/__tests__/account-notes.test.ts @@ -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); }); diff --git a/app/soapbox/actions/__tests__/accounts.test.ts b/app/soapbox/actions/__tests__/accounts.test.ts index d9faa0213..c13f8ef90 100644 --- a/app/soapbox/actions/__tests__/accounts.test.ts +++ b/app/soapbox/actions/__tests__/accounts.test.ts @@ -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); }); diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 06fe848e2..e4a10edd0 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -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}`)); } }); }; diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index 690b74540..ad760d916 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -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, diff --git a/app/soapbox/actions/importer/index.ts b/app/soapbox/actions/importer/index.ts index ec0ec3121..fc9ad63bd 100644 --- a/app/soapbox/actions/importer/index.ts +++ b/app/soapbox/actions/importer/index.ts @@ -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); }; diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index c236a2986..cd08fcd2f 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -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: {acct} }), confirm: intl.formatMessage(messages.deleteStatusConfirm), onConfirm: () => { dispatch(deleteStatus(statusId)).then(() => { diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index 6d64b6534..a2f165ac0 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -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 => { diff --git a/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts b/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts new file mode 100644 index 000000000..55a6f9459 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useDeleteGroupStatus.ts @@ -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 }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupMedia.ts b/app/soapbox/api/hooks/groups/useGroupMedia.ts index 23375bdc7..4db7fd179 100644 --- a/app/soapbox/api/hooks/groups/useGroupMedia.ts +++ b/app/soapbox/api/hooks/groups/useGroupMedia.ts @@ -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(); diff --git a/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts b/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts index 16ff924c7..a6e068091 100644 --- a/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts +++ b/app/soapbox/api/hooks/groups/useGroupMembershipRequests.ts @@ -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, diff --git a/app/soapbox/components/authorize-reject-buttons.tsx b/app/soapbox/components/authorize-reject-buttons.tsx index a5fddc61a..5dfb37a31 100644 --- a/app/soapbox/components/authorize-reject-buttons.tsx +++ b/app/soapbox/components/authorize-reject-buttons.tsx @@ -51,7 +51,7 @@ const AuthorizeRejectButtons: React.FC = ({ onAuthorize await action(); setState(past); } catch (e) { - console.error(e); + if (e) console.error(e); } }; if (typeof countdown === 'number') { diff --git a/app/soapbox/components/birthday-panel.tsx b/app/soapbox/components/birthday-panel.tsx index 059b8678b..bcfe5d073 100644 --- a/app/soapbox/components/birthday-panel.tsx +++ b/app/soapbox/components/birthday-panel.tsx @@ -58,6 +58,7 @@ const BirthdayPanel = ({ limit }: IBirthdayPanel) => { key={accountId} // @ts-ignore: TS thinks `id` is passed to , but it isn't id={accountId} + withRelationship={false} /> ))} diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx index 8b0ca7755..8a6c8f531 100644 --- a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -94,7 +94,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { > {item.icon && } - {item.text} + {item.text} {item.count ? ( diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index e1b8cdcd9..b56e0e6a7 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -56,8 +56,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick, onSelec return ( = ({ label, hint, children, onClick, onSelec {onClick ? ( - + {children} diff --git a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx index 29c841a0a..a9e709399 100644 --- a/app/soapbox/components/polls/__tests__/poll-footer.test.tsx +++ b/app/soapbox/components/polls/__tests__/poll-footer.test.tsx @@ -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('', () => { describe('with "showResults" enabled', () => { @@ -62,10 +70,10 @@ describe('', () => { 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('', () => { describe('when the Poll has expired', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, expired: true, - }); + }; }); it('renders closed', () => { @@ -100,10 +108,10 @@ describe('', () => { 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('', () => { describe('when the Poll is not multiple', () => { beforeEach(() => { - poll = normalizePoll({ - ...poll.toJS(), + poll = { + ...poll, multiple: false, - }); + }; }); it('does not render the Vote button', () => { diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx index c62cc9522..1994c1e76 100644 --- a/app/soapbox/components/polls/poll-footer.tsx +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -40,21 +40,21 @@ const PollFooter: React.FC = ({ poll, showResults, selected }): JSX let votesCount = null; if (poll.voters_count !== null && poll.voters_count !== undefined) { - votesCount = ; + votesCount = ; } else { - votesCount = ; + votesCount = ; } return ( - {(!showResults && poll?.multiple) && ( + {(!showResults && poll.multiple) && ( )} - {poll.pleroma.get('non_anonymous') && ( + {poll.pleroma?.non_anonymous && ( <> diff --git a/app/soapbox/components/polls/poll-option.tsx b/app/soapbox/components/polls/poll-option.tsx index 792a3a066..b4c37e11d 100644 --- a/app/soapbox/components/polls/poll-option.tsx +++ b/app/soapbox/components/polls/poll-option.tsx @@ -112,10 +112,13 @@ const PollOption: React.FC = (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 (
{showResults ? ( diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 585cc32d9..13e1a4a3c 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -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 = ({ 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 = ({ dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')} }} />, + heading: , + message: @{account.acct} }} />, confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => dispatch(blockAccount(account.id)), secondary: intl.formatMessage(messages.blockAndReport), @@ -313,31 +307,15 @@ const StatusActionBar: React.FC = ({ dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.deleteHeading), - message: intl.formatMessage(messages.deleteFromGroupMessage, { name: account.username }), + message: intl.formatMessage(messages.deleteFromGroupMessage, { name: {account.username} }), confirm: intl.formatMessage(messages.deleteConfirm), - onConfirm: () => dispatch(groupDeleteStatus((status.group as Group).id, status.id)), - })); - }; - - const handleKickFromGroup: React.EventHandler = () => { - 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 = () => { - 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 = ({ 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 = ({ 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 = ({ }); } - 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, }); } diff --git a/app/soapbox/components/status-reply-mentions.tsx b/app/soapbox/components/status-reply-mentions.tsx index 61f2f2969..e03d0c7f7 100644 --- a/app/soapbox/components/status-reply-mentions.tsx +++ b/app/soapbox/components/status-reply-mentions.tsx @@ -54,7 +54,7 @@ const StatusReplyMentions: React.FC = ({ status, hoverable e.stopPropagation()} > @{isPubkey(account.username) ? account.username.slice(0, 8) : account.username} diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 8456a46a4..0c1a7be59 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -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 = (props) => { const isUnderReview = actualStatus.visibility === 'self'; const isSensitive = actualStatus.hidden; + const isSoftDeleted = status.tombstone?.reason === 'deleted'; + + if (isSoftDeleted) { + return ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } return ( diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 6c6a2a6f9..b92fb7e70 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -19,10 +19,17 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { return ( -
- - - +
+
+ + + +
); diff --git a/app/soapbox/components/ui/modal/modal.tsx b/app/soapbox/components/ui/modal/modal.tsx index 98b001f4c..d7f29303f 100644 --- a/app/soapbox/components/ui/modal/modal.tsx +++ b/app/soapbox/components/ui/modal/modal.tsx @@ -102,7 +102,7 @@ const Modal = React.forwardRef(({ 'flex-row-reverse': closePosition === 'left', })} > -

+

{title}

diff --git a/app/soapbox/containers/group-container.tsx b/app/soapbox/containers/group-container.tsx deleted file mode 100644 index f1254b2ca..000000000 --- a/app/soapbox/containers/group-container.tsx +++ /dev/null @@ -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 = (props) => { - const { id, ...rest } = props; - - const getGroup = useCallback(makeGetGroup(), []); - const group = useAppSelector(state => getGroup(state, id)); - - if (group) { - return ; - } else { - return null; - } -}; - -export default GroupContainer; diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 4a589a328..fbdfc3181 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -130,7 +130,7 @@ const Header: React.FC = ({ account }) => { dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/ban.svg'), heading: , - message: @{account.acct} }} />, + message: @{account.acct} }} />, confirm: intl.formatMessage(messages.blockConfirm), onConfirm: () => dispatch(blockAccount(account.id)), secondary: intl.formatMessage(messages.blockAndReport), @@ -215,7 +215,7 @@ const Header: React.FC = ({ account }) => { const unfollowModal = getSettings(getState()).get('unfollowModal'); if (unfollowModal) { dispatch(openModal('CONFIRM', { - message: @{account.acct} }} />, + message: @{account.acct} }} />, confirm: intl.formatMessage(messages.removeFromFollowersConfirm), onConfirm: () => dispatch(removeFromFollowers(account.id)), })); diff --git a/app/soapbox/features/ads/providers/index.ts b/app/soapbox/features/ads/providers/index.ts index 8ff7d5219..63067b81d 100644 --- a/app/soapbox/features/ads/providers/index.ts +++ b/app/soapbox/features/ads/providers/index.ts @@ -6,7 +6,6 @@ import type { Card } from 'soapbox/types/entities'; /** Map of available provider modules. */ const PROVIDERS: Record Promise> = { 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, }; diff --git a/app/soapbox/features/ads/providers/rumble.ts b/app/soapbox/features/ads/providers/rumble.ts deleted file mode 100644 index 21dc6e7f3..000000000 --- a/app/soapbox/features/ads/providers/rumble.ts +++ /dev/null @@ -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(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; diff --git a/app/soapbox/features/ads/providers/truth.ts b/app/soapbox/features/ads/providers/truth.ts index 9207db522..5582bd3cf 100644 --- a/app/soapbox/features/ads/providers/truth.ts +++ b/app/soapbox/features/ads/providers/truth.ts @@ -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('/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 } diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx index 9a4768903..d7871fc75 100644 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ b/app/soapbox/features/auth-login/components/login-form.tsx @@ -57,7 +57,7 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { + ', () => { render( { - {(chatMessage.emoji_reactions?.size) ? ( + {(chatMessage.emoji_reactions?.length) ? (
= ({ className, status, hideActi hideActions={hideActions} /> - { 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) => ( - - )); - resultsIds = results.groups; - } else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) { - searchResults = null; - } else if (loaded) { - noResultsMessage = ( -
- -
- ); - } - } - if (selectedFilter === 'hashtags') { hasMore = results.hashtagsHasMore; loaded = results.hashtagsLoaded; @@ -238,11 +201,11 @@ const SearchResults = () => { {filterByAccount ? ( - + {account} }} /> diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 0e50fc0be..3a3bdcd6b 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -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 (
diff --git a/app/soapbox/features/edit-profile/index.tsx b/app/soapbox/features/edit-profile/index.tsx index 8483a171e..f45d75afa 100644 --- a/app/soapbox/features/edit-profile/index.tsx +++ b/app/soapbox/features/edit-profile/index.tsx @@ -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>('fields', ImmutableList()).toJS()], stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true, diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index 9d62538ce..f437aad0c 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -207,7 +207,7 @@ const FeedCarousel = () => { style={{ width: widthPerAvatar || 'auto' }} key={idx} > - +
)) ) : ( diff --git a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx index 6809ea009..a0df6affe 100644 --- a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx @@ -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('', () => { beforeEach(() => { group = buildGroup({ relationship: buildGroupRelationship({ - member: null, + member: false, }), }); }); @@ -98,7 +99,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'owner', + role: GroupRoles.OWNER, }), }); }); @@ -116,7 +117,7 @@ describe('', () => { relationship: buildGroupRelationship({ requested: false, member: true, - role: 'user', + role: GroupRoles.USER, }), }); }); diff --git a/app/soapbox/features/group/components/__tests__/group-header.test.tsx b/app/soapbox/features/group/components/__tests__/group-header.test.tsx new file mode 100644 index 000000000..03f171e14 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-header.test.tsx @@ -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('', () => { + describe('without a group', () => { + it('should render the blankslate', () => { + render(); + 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(); + + 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(); + + 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(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx new file mode 100644 index 000000000..abecc3287 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-member-list-item.test.tsx @@ -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('', () => { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(async() => { + expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx index 8704b9351..e3171bb81 100644 --- a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx @@ -17,7 +17,7 @@ describe('', () => { requested: false, member: true, blocked_by: true, - role: 'user', + role: GroupRoles.USER, }), }); }); diff --git a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx new file mode 100644 index 000000000..4418fff86 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx @@ -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('', () => { + describe('tag name', () => { + const name = 'hello'; + + it('should render the tag name', () => { + const group = buildGroup(); + const tag = buildGroupTag({ name }); + render(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); + }); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 9626906d5..ad22e1e13 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -34,7 +34,7 @@ const GroupHeader: React.FC = ({ group }) => { if (!group) { return ( -
+
@@ -107,7 +107,10 @@ const GroupHeader: React.FC = ({ group }) => { } return ( -
+
{isHeaderMissing ? ( ) : header} @@ -120,7 +123,7 @@ const GroupHeader: React.FC = ({ group }) => {
{renderHeader()} -
+
= ({ group }) => { size='xl' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} + data-testid='group-name' /> {!isDeleted && ( <> - + @@ -154,7 +158,7 @@ const GroupHeader: React.FC = ({ group }) => { /> - + diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index d2226879a..f9b18735d 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -180,7 +180,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { } return ( - +
@@ -188,6 +192,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { {(isMemberOwner || isMemberAdmin) ? ( { require('@tabler/icons/pin.svg') } iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' + data-testid='pin-icon' /> ); @@ -123,13 +124,18 @@ const GroupTagListItem = (props: IGroupMemberListItem) => { }; return ( - + #{tag.name} @@ -137,7 +143,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => { {intl.formatMessage(messages.total)}: {' '} - {shortNumberFormat(tag.groups)} + {shortNumberFormat(tag.uses)} diff --git a/app/soapbox/features/group/group-membership-requests.tsx b/app/soapbox/features/group/group-membership-requests.tsx index e50092887..79700e1a7 100644 --- a/app/soapbox/features/group/group-membership-requests.tsx +++ b/app/soapbox/features/group/group-membership-requests.tsx @@ -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 = ({ 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 = ({ 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 ( diff --git a/app/soapbox/features/group/manage-group.tsx b/app/soapbox/features/group/manage-group.tsx index 648593318..d57b8792a 100644 --- a/app/soapbox/features/group/manage-group.tsx +++ b/app/soapbox/features/group/manage-group.tsx @@ -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' }, }); diff --git a/app/soapbox/features/placeholder/components/placeholder-avatar.tsx b/app/soapbox/features/placeholder/components/placeholder-avatar.tsx index 6eb479f13..058904001 100644 --- a/app/soapbox/features/placeholder/components/placeholder-avatar.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-avatar.tsx @@ -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 = ({ size, withText = false }) => { +const PlaceholderAvatar: React.FC = ({ size, withText = false, className }) => { const style = React.useMemo(() => { if (!size) { return {}; @@ -21,7 +23,10 @@ const PlaceholderAvatar: React.FC = ({ size, withText = fals }, [size]); return ( - +
= ({ variant = 'rounded' }) => ( +const PlaceholderStatus: React.FC = ({ variant }) => (
{ - {displayName} + {displayName} diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 2959d285d..15f2e86e8 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -125,7 +125,6 @@ const DetailedStatus: React.FC = ({ = ({ count, onClick, chi } > - + {shortNumberFormat(count)} diff --git a/app/soapbox/features/status/components/thread-status.tsx b/app/soapbox/features/status/components/thread-status.tsx index c3ff7bc65..442bae0e3 100644 --- a/app/soapbox/features/status/components/thread-status.tsx +++ b/app/soapbox/features/status/components/thread-status.tsx @@ -31,9 +31,8 @@ const ThreadStatus: React.FC = (props): JSX.Element => { return ( ); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 64eb9404f..6b41148c4 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -404,7 +404,7 @@ const Thread: React.FC = (props) => { useEffect(() => { scroller.current?.scrollToIndex({ index: ancestorsIds.size, - offset: -140, + offset: -146, }); setImmediate(() => statusRef.current?.querySelector('.detailed-actualStatus')?.focus()); @@ -443,7 +443,9 @@ const Thread: React.FC = (props) => { ); } else if (!status) { return ( - + + + ); } diff --git a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx index d0ec92f96..5edc9636b 100644 --- a/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/subscribe-button.test.tsx @@ -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('', () => { 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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, null, store); - // expect(screen.getByTestId('icon-button').title).toEqual(`Subscribe to notifications from @${account.acct}`); - // }); - // }); - // }); - // }); - }); diff --git a/app/soapbox/features/ui/components/group-media-panel.tsx b/app/soapbox/features/ui/components/group-media-panel.tsx index 1d9428f33..9f52af5a2 100644 --- a/app/soapbox/features/ui/components/group-media-panel.tsx +++ b/app/soapbox/features/ui/components/group-media-panel.tsx @@ -40,7 +40,7 @@ const GroupMediaPanel: React.FC = ({ 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 = ({ group }) => { } }; - if (isPrivate && !isMember) { + if ((isPrivate && !isMember) || group?.deleted_at) { return null; } diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx index 4c388751f..11244c3c5 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx @@ -19,7 +19,7 @@ const ConfirmationStep: React.FC = ({ group }) => { const intl = useIntl(); const handleCopyLink = () => { - copy(`${window.location.origin}/group/${group?.slug}`, () => { + copy(group?.url as string, () => { toast.success(intl.formatMessage(messages.copied)); }); }; diff --git a/app/soapbox/features/ui/components/modals/mute-modal.tsx b/app/soapbox/features/ui/components/modals/mute-modal.tsx index 5c8dd603e..90f32ec35 100644 --- a/app/soapbox/features/ui/components/modals/mute-modal.tsx +++ b/app/soapbox/features/ui/components/modals/mute-modal.tsx @@ -60,7 +60,7 @@ const MuteModal = () => { @{account.acct} }} + values={{ name: @{account.acct} }} /> diff --git a/app/soapbox/features/ui/components/profile-info-panel.tsx b/app/soapbox/features/ui/components/profile-info-panel.tsx index 6d407eaab..39128561d 100644 --- a/app/soapbox/features/ui/components/profile-info-panel.tsx +++ b/app/soapbox/features/ui/components/profile-info-panel.tsx @@ -120,7 +120,7 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => - + @{username} diff --git a/app/soapbox/features/ui/components/user-panel.tsx b/app/soapbox/features/ui/components/user-panel.tsx index 68165ba24..08642d801 100644 --- a/app/soapbox/features/ui/components/user-panel.tsx +++ b/app/soapbox/features/ui/components/user-panel.tsx @@ -59,7 +59,7 @@ const UserPanel: React.FC = ({ accountId, action, badges, domain }) - + {verified && } @@ -71,7 +71,7 @@ const UserPanel: React.FC = ({ accountId, action, badges, domain }) - + @{getAcct(account, fqn)} diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index e5bdd796b..35ea063e0 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -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 = {}): Group { +function buildAccount(props: Partial = {}): Account { + return accountSchema.parse(Object.assign({ + id: uuidv4(), + }, props)); +} + +function buildCard(props: Partial = {}): Card { + return cardSchema.parse(Object.assign({ + url: 'https://soapbox.test', + }, props)); +} + +function buildGroup(props: Partial = {}): Group { return groupSchema.parse(Object.assign({ id: uuidv4(), }, props)); } -function buildGroupRelationship(props: Record = {}): GroupRelationship { +function buildGroupRelationship(props: Partial = {}): GroupRelationship { return groupRelationshipSchema.parse(Object.assign({ id: uuidv4(), }, props)); } -function buildGroupTag(props: Record = {}): GroupTag { +function buildGroupTag(props: Partial = {}): GroupTag { return groupTagSchema.parse(Object.assign({ id: uuidv4(), + name: uuidv4(), + }, props)); +} + +function buildGroupMember( + props: Partial = {}, + accountProps: Partial = {}, +): GroupMember { + return groupMemberSchema.parse(Object.assign({ + id: uuidv4(), + account: buildAccount(accountProps), + role: GroupRoles.USER, + }, props)); +} + +function buildAd(props: Partial = {}): Ad { + return adSchema.parse(Object.assign({ + card: buildCard(), + }, props)); +} + +function buildRelationship(props: Partial = {}): Relationship { + return relationshipSchema.parse(Object.assign({ + id: uuidv4(), }, props)); } -export { buildGroup, buildGroupRelationship, buildGroupTag }; \ No newline at end of file +export { + buildAd, + buildCard, + buildGroup, + buildGroupMember, + buildGroupRelationship, + buildGroupTag, + buildRelationship, +}; \ No newline at end of file diff --git a/app/soapbox/locales/ar.json b/app/soapbox/locales/ar.json index d732dfe22..3a898c760 100644 --- a/app/soapbox/locales/ar.json +++ b/app/soapbox/locales/ar.json @@ -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": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.", diff --git a/app/soapbox/locales/bn.json b/app/soapbox/locales/bn.json index 7694edf52..9871550f2 100644 --- a/app/soapbox/locales/bn.json +++ b/app/soapbox/locales/bn.json @@ -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": "নির্বাচনক্ষেত্রে কোনো ফাইল যুক্ত করা যাবেনা।", diff --git a/app/soapbox/locales/ca.json b/app/soapbox/locales/ca.json index 62f495db5..7b4638aad 100644 --- a/app/soapbox/locales/ca.json +++ b/app/soapbox/locales/ca.json @@ -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.", diff --git a/app/soapbox/locales/co.json b/app/soapbox/locales/co.json index 02113ca78..a25de09ce 100644 --- a/app/soapbox/locales/co.json +++ b/app/soapbox/locales/co.json @@ -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.", diff --git a/app/soapbox/locales/cs.json b/app/soapbox/locales/cs.json index 0811858bd..b1f0b7403 100644 --- a/app/soapbox/locales/cs.json +++ b/app/soapbox/locales/cs.json @@ -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.", diff --git a/app/soapbox/locales/cy.json b/app/soapbox/locales/cy.json index 1b202dc0b..aeb6b1564 100644 --- a/app/soapbox/locales/cy.json +++ b/app/soapbox/locales/cy.json @@ -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.", diff --git a/app/soapbox/locales/da.json b/app/soapbox/locales/da.json index f24d73e4d..a24867cd9 100644 --- a/app/soapbox/locales/da.json +++ b/app/soapbox/locales/da.json @@ -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.", diff --git a/app/soapbox/locales/de.json b/app/soapbox/locales/de.json index 9b15656fc..ff13906c1 100644 --- a/app/soapbox/locales/de.json +++ b/app/soapbox/locales/de.json @@ -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.", diff --git a/app/soapbox/locales/el.json b/app/soapbox/locales/el.json index 359ce8ce0..13761643f 100644 --- a/app/soapbox/locales/el.json +++ b/app/soapbox/locales/el.json @@ -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": "Υπέρβαση ορίου μεγέθους ανεβασμένων αρχείων.", diff --git a/app/soapbox/locales/en-Shaw.json b/app/soapbox/locales/en-Shaw.json index b2e169b4c..46d44dd93 100644 --- a/app/soapbox/locales/en-Shaw.json +++ b/app/soapbox/locales/en-Shaw.json @@ -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": "𐑓𐑲𐑤 𐑳𐑐𐑤𐑴𐑛 𐑤𐑦𐑥𐑦𐑑 𐑦𐑒𐑕𐑰𐑛𐑩𐑛.", diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 0576f8576..11badebe2 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -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.", diff --git a/app/soapbox/locales/eo.json b/app/soapbox/locales/eo.json index 6a3cb8129..7d0fc20d6 100644 --- a/app/soapbox/locales/eo.json +++ b/app/soapbox/locales/eo.json @@ -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.", diff --git a/app/soapbox/locales/es-AR.json b/app/soapbox/locales/es-AR.json index 1af32095e..82d8881d3 100644 --- a/app/soapbox/locales/es-AR.json +++ b/app/soapbox/locales/es-AR.json @@ -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.", diff --git a/app/soapbox/locales/es.json b/app/soapbox/locales/es.json index 4b41887f6..685fbd0c4 100644 --- a/app/soapbox/locales/es.json +++ b/app/soapbox/locales/es.json @@ -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.", diff --git a/app/soapbox/locales/et.json b/app/soapbox/locales/et.json index df503b6c0..77ae38b80 100644 --- a/app/soapbox/locales/et.json +++ b/app/soapbox/locales/et.json @@ -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.", diff --git a/app/soapbox/locales/eu.json b/app/soapbox/locales/eu.json index b8ed28023..fc7c8a5b1 100644 --- a/app/soapbox/locales/eu.json +++ b/app/soapbox/locales/eu.json @@ -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.", diff --git a/app/soapbox/locales/fa.json b/app/soapbox/locales/fa.json index ca1f611ea..dba8e1a91 100644 --- a/app/soapbox/locales/fa.json +++ b/app/soapbox/locales/fa.json @@ -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": "از حد مجاز باگذاری فراتر رفتید.", diff --git a/app/soapbox/locales/fi.json b/app/soapbox/locales/fi.json index 77ffb7df8..c2e5b2c98 100644 --- a/app/soapbox/locales/fi.json +++ b/app/soapbox/locales/fi.json @@ -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ä.", diff --git a/app/soapbox/locales/fr.json b/app/soapbox/locales/fr.json index 7a4f9730c..e2421e71e 100644 --- a/app/soapbox/locales/fr.json +++ b/app/soapbox/locales/fr.json @@ -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.", diff --git a/app/soapbox/locales/ga.json b/app/soapbox/locales/ga.json index bfdeb9469..278d41ace 100644 --- a/app/soapbox/locales/ga.json +++ b/app/soapbox/locales/ga.json @@ -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.", diff --git a/app/soapbox/locales/gl.json b/app/soapbox/locales/gl.json index f24273379..406c6f980 100644 --- a/app/soapbox/locales/gl.json +++ b/app/soapbox/locales/gl.json @@ -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.", diff --git a/app/soapbox/locales/he.json b/app/soapbox/locales/he.json index 13b3636a3..95444dc6b 100644 --- a/app/soapbox/locales/he.json +++ b/app/soapbox/locales/he.json @@ -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": "חרגת ממגבלת העלאת הקבצים.", diff --git a/app/soapbox/locales/hi.json b/app/soapbox/locales/hi.json index 6607823e2..2308ef9dd 100644 --- a/app/soapbox/locales/hi.json +++ b/app/soapbox/locales/hi.json @@ -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.", diff --git a/app/soapbox/locales/hr.json b/app/soapbox/locales/hr.json index e1d08b7f0..6c7237da1 100644 --- a/app/soapbox/locales/hr.json +++ b/app/soapbox/locales/hr.json @@ -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.", diff --git a/app/soapbox/locales/hu.json b/app/soapbox/locales/hu.json index 3465572d8..725b2bda7 100644 --- a/app/soapbox/locales/hu.json +++ b/app/soapbox/locales/hu.json @@ -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.", diff --git a/app/soapbox/locales/hy.json b/app/soapbox/locales/hy.json index 52b8c67f1..9647f6461 100644 --- a/app/soapbox/locales/hy.json +++ b/app/soapbox/locales/hy.json @@ -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": "Հետարկել", diff --git a/app/soapbox/locales/id.json b/app/soapbox/locales/id.json index 04ce194e6..f4614f33f 100644 --- a/app/soapbox/locales/id.json +++ b/app/soapbox/locales/id.json @@ -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.", diff --git a/app/soapbox/locales/io.json b/app/soapbox/locales/io.json index c272f8a1a..eea03929f 100644 --- a/app/soapbox/locales/io.json +++ b/app/soapbox/locales/io.json @@ -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.", diff --git a/app/soapbox/locales/is.json b/app/soapbox/locales/is.json index 0f36a744b..77da0b6a4 100644 --- a/app/soapbox/locales/is.json +++ b/app/soapbox/locales/is.json @@ -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.", diff --git a/app/soapbox/locales/it.json b/app/soapbox/locales/it.json index ecb533320..dcdfda2f8 100644 --- a/app/soapbox/locales/it.json +++ b/app/soapbox/locales/it.json @@ -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.", diff --git a/app/soapbox/locales/ja.json b/app/soapbox/locales/ja.json index c012a65e4..b94bd46db 100644 --- a/app/soapbox/locales/ja.json +++ b/app/soapbox/locales/ja.json @@ -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": "アップロードできる上限を超えています。", diff --git a/app/soapbox/locales/ka.json b/app/soapbox/locales/ka.json index 22c4c7a3b..55b0f0499 100644 --- a/app/soapbox/locales/ka.json +++ b/app/soapbox/locales/ka.json @@ -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": "აღწერილობა ვიზუალურად უფასურისთვის", diff --git a/app/soapbox/locales/kk.json b/app/soapbox/locales/kk.json index 048c7d26a..de3f7d434 100644 --- a/app/soapbox/locales/kk.json +++ b/app/soapbox/locales/kk.json @@ -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": "Файл жүктеу лимитінен асып кеттіңіз.", diff --git a/app/soapbox/locales/ko.json b/app/soapbox/locales/ko.json index dc71d944f..d733e326d 100644 --- a/app/soapbox/locales/ko.json +++ b/app/soapbox/locales/ko.json @@ -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": "파일 업로드 제한에 도달했습니다.", diff --git a/app/soapbox/locales/lt.json b/app/soapbox/locales/lt.json index f7a6feb0a..90149ded3 100644 --- a/app/soapbox/locales/lt.json +++ b/app/soapbox/locales/lt.json @@ -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.", diff --git a/app/soapbox/locales/lv.json b/app/soapbox/locales/lv.json index 1273f390d..c49b0b0eb 100644 --- a/app/soapbox/locales/lv.json +++ b/app/soapbox/locales/lv.json @@ -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.", diff --git a/app/soapbox/locales/mk.json b/app/soapbox/locales/mk.json index 4a8280b12..67c1e6590 100644 --- a/app/soapbox/locales/mk.json +++ b/app/soapbox/locales/mk.json @@ -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.", diff --git a/app/soapbox/locales/ms.json b/app/soapbox/locales/ms.json index ef8885c64..6d5678b52 100644 --- a/app/soapbox/locales/ms.json +++ b/app/soapbox/locales/ms.json @@ -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.", diff --git a/app/soapbox/locales/nl.json b/app/soapbox/locales/nl.json index 7e3f3e1dc..aed0ba5e1 100644 --- a/app/soapbox/locales/nl.json +++ b/app/soapbox/locales/nl.json @@ -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.", diff --git a/app/soapbox/locales/nn.json b/app/soapbox/locales/nn.json index e3339e389..f1e0db882 100644 --- a/app/soapbox/locales/nn.json +++ b/app/soapbox/locales/nn.json @@ -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.", diff --git a/app/soapbox/locales/no.json b/app/soapbox/locales/no.json index 83c890d89..a24117227 100644 --- a/app/soapbox/locales/no.json +++ b/app/soapbox/locales/no.json @@ -807,9 +807,11 @@ "group.role.admin": "Administrator", "group.role.owner": "Eier", "group.tabs.all": "Alle", + "group.tabs.media": "Media", "group.tabs.members": "Medlemmer", "group.tags.hint": "Legg til maks. 3 nøkkelord som tjener som kjerneemner for diskusjoner i gruppen.", "group.tags.label": "Etiketter", + "group.update.success": "Gruppe lagret", "group.upload_banner": "Last opp bilde", "groups.discover.popular.empty": "Kunne ikke hente populære grupper nå. Sjekk igjen senere.", "groups.discover.popular.show_more": "Vis mer", @@ -964,7 +966,6 @@ "manage_group.privacy.private.label": "Privat (Eierens godkjenning kreves)", "manage_group.privacy.public.hint": "Kan oppdages. Alle kan bli med.", "manage_group.privacy.public.label": "Offentlig", - "manage_group.success": "Gruppe lagret.", "manage_group.tagline": "Grupper setter deg i kontakt med andre basert på felles interesser.", "media_panel.empty_message": "Ingen medier funnet.", "media_panel.title": "Media", @@ -1516,7 +1517,6 @@ "trendsPanel.viewAll": "Vis alle", "unauthorized_modal.text": "Du må være logget inn for å gjøre det.", "unauthorized_modal.title": "Sign up for {site_title}", - "upload_area.title": "Dra og slipp for å laste opp", "upload_button.label": "Legg til media", "upload_error.image_size_limit": "Bildet overskrider gjeldende filstørrelsesgrense ({limit})", "upload_error.limit": "Filopplastingsgrensen er overskredet.", diff --git a/app/soapbox/locales/oc.json b/app/soapbox/locales/oc.json index a4ce6b548..2bff8c415 100644 --- a/app/soapbox/locales/oc.json +++ b/app/soapbox/locales/oc.json @@ -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": "Lisatz e depausatz per mandar", "upload_button.label": "Ajustar 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": "Talha maximum pels mandadís subrepassada.", diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index df7f86c52..0981ede80 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -1285,7 +1285,7 @@ "status.show_less_all": "Zwiń wszystkie", "status.show_more_all": "Rozwiń wszystkie", "status.show_original": "Pokaż oryginalny wpis", - "status.title": "Wpis @{username}", + "status.title": "Wpis", "status.title_direct": "Wiadomość bezpośrednia", "status.translate": "Przetłumacz wpis", "status.translated_from_with": "Przetłumaczono z {lang} z użyciem {provider}", @@ -1337,7 +1337,6 @@ "trendsPanel.viewAll": "Pokaż wszystkie", "unauthorized_modal.text": "Musisz się zalogować, aby to zrobić.", "unauthorized_modal.title": "Zarejestruj się na {site_title}", - "upload_area.title": "Przeciągnij i upuść aby wysłać", "upload_button.label": "Dodaj zawartość multimedialną (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.image_size_limit": "Obraz przekracza limit rozmiaru plików ({limit})", "upload_error.limit": "Przekroczono limit plików do wysłania.", diff --git a/app/soapbox/locales/pt-BR.json b/app/soapbox/locales/pt-BR.json index 79ebcaab9..f53311155 100644 --- a/app/soapbox/locales/pt-BR.json +++ b/app/soapbox/locales/pt-BR.json @@ -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": "Arraste e solte para enviar", "upload_button.label": "Adicionar mídia (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "Limite de envio de arquivos excedido.", diff --git a/app/soapbox/locales/pt.json b/app/soapbox/locales/pt.json index e3fc41a9d..cf9fb910e 100644 --- a/app/soapbox/locales/pt.json +++ b/app/soapbox/locales/pt.json @@ -1130,7 +1130,6 @@ "trendsPanel.viewAll": "View all", "unauthorized_modal.text": "Deves ter a sessão iniciada para realizares essa ação.", "unauthorized_modal.title": "Registar no {site_title}", - "upload_area.title": "Arrasta e larga para enviar", "upload_button.label": "Adicionar multimédia", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "O limite máximo do ficheiro a carregar foi excedido.", diff --git a/app/soapbox/locales/ro.json b/app/soapbox/locales/ro.json index b089675ba..7ee15d5f9 100644 --- a/app/soapbox/locales/ro.json +++ b/app/soapbox/locales/ro.json @@ -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": "Trage și eliberează pentru a încărca", "upload_button.label": "Adaugă 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.", diff --git a/app/soapbox/locales/ru.json b/app/soapbox/locales/ru.json index 818f1092d..a19cab662 100644 --- a/app/soapbox/locales/ru.json +++ b/app/soapbox/locales/ru.json @@ -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": "Добавить медиаконтент", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "Достигнут лимит загруженных файлов.", diff --git a/app/soapbox/locales/sk.json b/app/soapbox/locales/sk.json index 6093583a3..f391d77ea 100644 --- a/app/soapbox/locales/sk.json +++ b/app/soapbox/locales/sk.json @@ -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": "Pretiahni a pusť pre nahratie", "upload_button.label": "Pridaj médiálny súbor (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "Limit pre nahrávanie súborov bol prekročený.", diff --git a/app/soapbox/locales/sl.json b/app/soapbox/locales/sl.json index 2267c1b6b..c310438b8 100644 --- a/app/soapbox/locales/sl.json +++ b/app/soapbox/locales/sl.json @@ -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": "Za pošiljanje povlecite in spustite", "upload_button.label": "Dodaj medij", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "Omejitev prenosa datoteke je presežena.", diff --git a/app/soapbox/locales/sq.json b/app/soapbox/locales/sq.json index 389db0be2..ff08a559c 100644 --- a/app/soapbox/locales/sq.json +++ b/app/soapbox/locales/sq.json @@ -399,7 +399,6 @@ "toast.view": "View", "trends.count_by_accounts": "{count} {rawCount, plural, one {person duke folur} other {persona që flasin}}", "unauthorized_modal.title": "Sign up for {site_title}", - "upload_area.title": "Merreni & vëreni që të ngarkohet", "upload_button.label": "Shtoni media (JPEG, PNG, GIF, WebM, MP4, MOV)", "upload_error.limit": "U tejkalua kufi ngarkimi kartelash.", "upload_form.description": "Përshkruajeni për persona me probleme shikimi", diff --git a/app/soapbox/locales/sr-Latn.json b/app/soapbox/locales/sr-Latn.json index 51157cb08..7cf1217bf 100644 --- a/app/soapbox/locales/sr-Latn.json +++ b/app/soapbox/locales/sr-Latn.json @@ -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": "Prevucite ovde da otpremite", "upload_button.label": "Dodaj multimediju", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "File upload limit exceeded.", diff --git a/app/soapbox/locales/sr.json b/app/soapbox/locales/sr.json index a411c3451..598ab4325 100644 --- a/app/soapbox/locales/sr.json +++ b/app/soapbox/locales/sr.json @@ -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": "File upload limit exceeded.", diff --git a/app/soapbox/locales/sv.json b/app/soapbox/locales/sv.json index 208627c1d..0eb2f231f 100644 --- a/app/soapbox/locales/sv.json +++ b/app/soapbox/locales/sv.json @@ -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": "Dra & släpp för att ladda upp", "upload_button.label": "Lägg till media", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "File upload limit exceeded.", diff --git a/app/soapbox/locales/ta.json b/app/soapbox/locales/ta.json index c9584dfed..a00885a55 100644 --- a/app/soapbox/locales/ta.json +++ b/app/soapbox/locales/ta.json @@ -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": "கோப்பு பதிவேற்ற வரம்பு மீறப்பட்டது.", diff --git a/app/soapbox/locales/te.json b/app/soapbox/locales/te.json index 963317686..734003c53 100644 --- a/app/soapbox/locales/te.json +++ b/app/soapbox/locales/te.json @@ -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": "File upload limit exceeded.", diff --git a/app/soapbox/locales/th.json b/app/soapbox/locales/th.json index ce698efb5..10288886e 100644 --- a/app/soapbox/locales/th.json +++ b/app/soapbox/locales/th.json @@ -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": "เกินขีดจำกัดการอัปโหลดไฟล์", diff --git a/app/soapbox/locales/tr.json b/app/soapbox/locales/tr.json index 35cc5120d..9aaf20fda 100644 --- a/app/soapbox/locales/tr.json +++ b/app/soapbox/locales/tr.json @@ -963,7 +963,6 @@ "manage_group.privacy.private.label": "Özel (Grup sahibi onayı gereklidir)", "manage_group.privacy.public.hint": "Keşfedilebilir. Herkes katılabilir.", "manage_group.privacy.public.label": "Herkese açık", - "manage_group.success": "Grup kaydedildi!", "manage_group.tagline": "Gruplar sizi ortak ilgi alanlarına göre başkalarıyla buluşturur.", "media_panel.empty_message": "Medya bulunamadı.", "media_panel.title": "Medya", @@ -1514,7 +1513,6 @@ "trendsPanel.viewAll": "Tümünü gör", "unauthorized_modal.text": "Bunu yapabilmek için giriş yapmış olmalısınız.", "unauthorized_modal.title": "{site_title} için kaydolun", - "upload_area.title": "Dosya yüklemek için sürükle bırak yapınız", "upload_button.label": "Görsel ekle", "upload_error.image_size_limit": "Resim geçerli dosya boyutu sınırını ({limit}) aşıyor", "upload_error.limit": "Dosya yükleme sınırı aşıldı.", diff --git a/app/soapbox/locales/uk.json b/app/soapbox/locales/uk.json index 915397a47..79b068ce0 100644 --- a/app/soapbox/locales/uk.json +++ b/app/soapbox/locales/uk.json @@ -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": "Додати медіаконтент", "upload_error.image_size_limit": "Image exceeds the current file size limit ({limit})", "upload_error.limit": "Ліміт завантаження файлів перевищено.", diff --git a/app/soapbox/locales/zh-CN.json b/app/soapbox/locales/zh-CN.json index 889752971..ff40ba578 100644 --- a/app/soapbox/locales/zh-CN.json +++ b/app/soapbox/locales/zh-CN.json @@ -171,8 +171,8 @@ "aliases.account_label": "旧帐号:", "aliases.aliases_list_delete": "删除别名", "aliases.search": "搜索旧帐号", - "aliases.success.add": "帐号别名创建成功", - "aliases.success.remove": "帐号别名删除成功", + "aliases.success.add": "帐号别名已成功创建", + "aliases.success.remove": "帐号别名已成功删除", "announcements.title": "公告", "app_create.name_label": "应用名称", "app_create.name_placeholder": "例如:'Soapbox'", @@ -180,7 +180,7 @@ "app_create.restart": "创建另一个", "app_create.results.app_label": "应用", "app_create.results.explanation_text": "您已创建一个新应用及其令牌,请复制密钥等信息,离开此页面后这些信息将不再展示。", - "app_create.results.explanation_title": "应用创建成功", + "app_create.results.explanation_title": "应用已成功创建", "app_create.results.token_label": "OAuth令牌", "app_create.scopes_label": "权限范围", "app_create.scopes_placeholder": "例如 '读取 写入 关注'", @@ -606,7 +606,7 @@ "edit_profile.hints.meta_fields": "您最多能在个人资料页面上展示 {count} 条自定义字段。", "edit_profile.hints.stranger_notifications": "仅显示来自您关注的人的通知", "edit_profile.save": "保存", - "edit_profile.success": "个人资料已保存!", + "edit_profile.success": "您的个人资料已成功保存!", "email_confirmation.success": "您的电子邮件已被确认!", "email_passthru.confirmed.body": "关闭此标签页,并在您发送此电子邮件确认的 {bold} 上继续注册过程。", "email_passthru.confirmed.heading": "邮箱地址已确认!", @@ -624,7 +624,7 @@ "email_verification.email.label": "电子邮箱地址", "email_verification.fail": "请求电子邮件验证失败。", "email_verification.header": "输入您的电子邮件地址", - "email_verification.success": "验证邮件发送成功。", + "email_verification.success": "验证邮件已成功发送。", "email_verification.taken": "被占用", "email_verifilcation.exists": "此电子邮件已被占用。", "embed.instructions": "要在您的站点上嵌入此帖文,请复制以下代码。", @@ -730,9 +730,9 @@ "export_data.hints.follows": "下载关注列表CSV文件", "export_data.hints.mutes": "下载静音列表CSV文件", "export_data.mutes_label": "静音", - "export_data.success.blocks": "屏蔽列表导出完毕", - "export_data.success.followers": "关注列表导出完毕", - "export_data.success.mutes": "静音列表导出完毕", + "export_data.success.blocks": "屏蔽列表已成功导出", + "export_data.success.followers": "关注列表已成功导出", + "export_data.success.mutes": "静音列表已成功导出", "federation_restriction.federated_timeline_removal": "从联邦宇宙时间轴移除", "federation_restriction.followers_only": "仅关注者可见", "federation_restriction.full_media_removal": "完全移除媒体", @@ -807,9 +807,22 @@ "group.role.admin": "管理员", "group.role.owner": "拥有者", "group.tabs.all": "全部", + "group.tabs.media": "媒体", "group.tabs.members": "成员", + "group.tabs.tags": "主题", + "group.tags.empty": "此群组中尚无主题。", + "group.tags.hidden.success": "标记为隐藏的主题", + "group.tags.hide": "隐藏主题", "group.tags.hint": "最多添加 3 个关键词,这些关键词将作为群组讨论的核心话题。", "group.tags.label": "标签", + "group.tags.pin": "置顶主题", + "group.tags.pin.success": "已置顶!", + "group.tags.show": "显示主题", + "group.tags.total": "帖文总数", + "group.tags.unpin": "取消置顶主题", + "group.tags.unpin.success": "已取消置顶!", + "group.tags.visible.success": "主题已标记为可见", + "group.update.success": "群组已成功保存", "group.upload_banner": "已上传照片", "groups.discover.popular.empty": "目前无法获取热门群组。请稍后再试。", "groups.discover.popular.show_more": "显示更多", @@ -828,6 +841,10 @@ "groups.discover.suggested.empty": "目前无法获取推荐群组。请稍后再试。", "groups.discover.suggested.show_more": "显示更多", "groups.discover.suggested.title": "为您推荐", + "groups.discover.tags.empty": "目前无法获取热门主题。请稍后再试。", + "groups.discover.tags.show_more": "显示更多", + "groups.discover.tags.title": "浏览主题", + "groups.discovery.tags.no_of_groups": "群组数", "groups.empty.subtitle": "开始发现可加入的群组或创建您自己的群组。", "groups.empty.title": "尚无群组", "groups.pending.count": "{number} 条待处理的申请", @@ -836,10 +853,12 @@ "groups.pending.label": "待处理的申请", "groups.popular.label": "推荐群组", "groups.search.placeholder": "搜索我的群组", + "groups.tags.title": "浏览主题", "hashtag.column_header.tag_mode.all": "以及{additional}", "hashtag.column_header.tag_mode.any": "或是{additional}", "hashtag.column_header.tag_mode.none": "而不用{additional}", "header.home.label": "主页", + "header.login.email.placeholder": "电子邮箱地址", "header.login.forgot_password": "忘记了密码?", "header.login.label": "登录", "header.login.password.label": "密码", @@ -860,9 +879,9 @@ "import_data.hints.follows": "上传包含关注帐号列表的CSV文件", "import_data.hints.mutes": "上传包含静音帐号列表的CSV文件", "import_data.mutes_label": "静音帐号", - "import_data.success.blocks": "屏蔽帐号列表导入完成", - "import_data.success.followers": "关注帐号列表导入完成", - "import_data.success.mutes": "静音帐号列表导入完成", + "import_data.success.blocks": "屏蔽帐号列表已成功导入", + "import_data.success.followers": "关注帐号列表已成功导入", + "import_data.success.mutes": "静音帐号列表已成功导入", "input.copy": "复制", "input.password.hide_password": "隐藏密码", "input.password.show_password": "显示密码", @@ -924,6 +943,7 @@ "lists.subheading": "您的列表", "loading_indicator.label": "加载中……", "location_search.placeholder": "寻找一个地址", + "login.fields.email_label": "电子邮箱地址", "login.fields.instance_label": "实例", "login.fields.instance_placeholder": "example.com", "login.fields.otp_code_hint": "输入双重认证应用里的代码,或者输入恢复代码", @@ -966,7 +986,6 @@ "manage_group.privacy.private.label": "私有(需要群组所有者批准)", "manage_group.privacy.public.hint": "可发现。任何人都可以加入。", "manage_group.privacy.public.label": "公开", - "manage_group.success": "群组已保存!", "manage_group.tagline": "群组根据共同的兴趣将您与他人联系起来。", "media_panel.empty_message": "未找到媒体。", "media_panel.title": "媒体", @@ -994,7 +1013,7 @@ "migration.hint.link": "创建一个帐号别名", "migration.move_account.fail": "帐号迁移失败。", "migration.move_account.fail.cooldown_period": "您最近迁移了您的帐号。请稍后再试。", - "migration.move_account.success": "帐号迁移成功。", + "migration.move_account.success": "帐号已成功迁移。", "migration.submit": "迁移关注者", "missing_description_modal.cancel": "取消", "missing_description_modal.continue": "发布", @@ -1013,6 +1032,7 @@ "mute_modal.duration": "持续时间", "mute_modal.hide_notifications": "同时隐藏来自此用户的通知?", "navbar.login.action": "登录", + "navbar.login.email.placeholder": "电子邮箱地址", "navbar.login.forgot_password": "忘记密码?", "navbar.login.password.label": "密码", "navbar.login.username.placeholder": "邮箱或用户名", @@ -1115,6 +1135,7 @@ "onboarding.suggestions.title": "推荐帐号", "onboarding.view_feed": "查看时间轴", "password_reset.confirmation": "请查阅确认邮件。", + "password_reset.fields.email_placeholder": "电子邮箱地址", "password_reset.fields.username_placeholder": "电子邮件或用户名", "password_reset.header": "重置密码", "password_reset.reset": "重置密码", @@ -1298,7 +1319,7 @@ "security.codes.fail": "恢复代码错误", "security.confirm.fail": "密码错误,请重试。", "security.delete_account.fail": "删除帐号失败。", - "security.delete_account.success": "帐号删除成功。", + "security.delete_account.success": "帐号已成功删除。", "security.disable.fail": "密码错误,请重试。", "security.fields.email.label": "邮箱地址", "security.fields.new_password.label": "输入新密码", @@ -1314,9 +1335,9 @@ "security.text.delete.local": "要删除您的帐号,请输入您的密码,然后点击“删除帐号”。这是一个永久性操作,不能撤销。", "security.tokens.revoke": "撤销", "security.update_email.fail": "更新邮箱地址失败。", - "security.update_email.success": "邮箱地址已更新。", - "security.update_password.fail": "更改密码失败。", - "security.update_password.success": "密码已更改。", + "security.update_email.success": "邮箱地址已成功更新。", + "security.update_password.fail": "更新密码失败。", + "security.update_password.success": "密码已成功更新。", "settings.account_migration": "迁移帐号", "settings.change_email": "修改邮箱", "settings.change_password": "修改密码", @@ -1521,7 +1542,6 @@ "trendsPanel.viewAll": "查看全部", "unauthorized_modal.text": "您需要登录才能继续。", "unauthorized_modal.title": "注册 {site_title} 帐号", - "upload_area.title": "将文件拖放到此处开始上传", "upload_button.label": "添加媒体附件", "upload_error.image_size_limit": "图片超过了当前的文件大小限制({limit})", "upload_error.limit": "已超过文件上传限制。", diff --git a/app/soapbox/locales/zh-HK.json b/app/soapbox/locales/zh-HK.json index a007447e8..b57824417 100644 --- a/app/soapbox/locales/zh-HK.json +++ b/app/soapbox/locales/zh-HK.json @@ -1012,7 +1012,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": "圖片超出當前文件大小限制 ({limit})", "upload_error.limit": "已達到檔案上傳限制。", diff --git a/app/soapbox/locales/zh-TW.json b/app/soapbox/locales/zh-TW.json index 788c8cdab..a9ba226a1 100644 --- a/app/soapbox/locales/zh-TW.json +++ b/app/soapbox/locales/zh-TW.json @@ -1012,7 +1012,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": "圖片超出當前文件大小限制 ({limit})", "upload_error.limit": "已達到檔案上傳限制。", diff --git a/app/soapbox/normalizers/__tests__/card.test.ts b/app/soapbox/normalizers/__tests__/card.test.ts deleted file mode 100644 index fc8d06221..000000000 --- a/app/soapbox/normalizers/__tests__/card.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Record as ImmutableRecord } from 'immutable'; - -import { normalizeCard } from '../card'; - -describe('normalizeCard()', () => { - it('adds base fields', () => { - const card = {}; - const result = normalizeCard(card); - - expect(ImmutableRecord.isRecord(result)).toBe(true); - expect(result.type).toEqual('link'); - expect(result.url).toEqual(''); - }); -}); diff --git a/app/soapbox/normalizers/__tests__/poll.test.ts b/app/soapbox/normalizers/__tests__/poll.test.ts deleted file mode 100644 index d5226e938..000000000 --- a/app/soapbox/normalizers/__tests__/poll.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Record as ImmutableRecord } from 'immutable'; - -import { normalizePoll } from '../poll'; - -describe('normalizePoll()', () => { - it('adds base fields', () => { - const poll = { options: [{ title: 'Apples' }] }; - const result = normalizePoll(poll); - - const expected = { - options: [{ title: 'Apples', votes_count: 0 }], - emojis: [], - expired: false, - multiple: false, - voters_count: 0, - votes_count: 0, - own_votes: null, - voted: false, - }; - - expect(ImmutableRecord.isRecord(result)).toBe(true); - expect(ImmutableRecord.isRecord(result.options.get(0))).toBe(true); - expect(result.toJS()).toMatchObject(expected); - }); - - it('normalizes a Pleroma logged-out poll', () => { - const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json'); - const result = normalizePoll(poll); - - // Adds logged-in fields - expect(result.voted).toBe(false); - expect(result.own_votes).toBe(null); - }); - - it('normalizes poll with emojis', () => { - const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json'); - const result = normalizePoll(poll); - - // Emojifies poll options - expect(result.options.get(1)?.title_emojified) - .toContain('emojione'); - - // Parses emojis as Immutable.Record's - expect(ImmutableRecord.isRecord(result.emojis.get(0))).toBe(true); - expect(result.emojis.get(1)?.shortcode).toEqual('soapbox'); - }); -}); diff --git a/app/soapbox/normalizers/__tests__/status.test.ts b/app/soapbox/normalizers/__tests__/status.test.ts index 5ad29d926..0024a212c 100644 --- a/app/soapbox/normalizers/__tests__/status.test.ts +++ b/app/soapbox/normalizers/__tests__/status.test.ts @@ -146,12 +146,16 @@ describe('normalizeStatus()', () => { }); it('normalizes poll and poll options', () => { - const status = { poll: { options: [{ title: 'Apples' }] } }; + const status = { poll: { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] } }; const result = normalizeStatus(status); const poll = result.poll as Poll; const expected = { - options: [{ title: 'Apples', votes_count: 0 }], + id: '1', + options: [ + { title: 'Apples', votes_count: 0 }, + { title: 'Oranges', votes_count: 0 }, + ], emojis: [], expired: false, multiple: false, @@ -161,9 +165,7 @@ describe('normalizeStatus()', () => { voted: false, }; - expect(ImmutableRecord.isRecord(poll)).toBe(true); - expect(ImmutableRecord.isRecord(poll.options.get(0))).toBe(true); - expect(poll.toJS()).toMatchObject(expected); + expect(poll).toMatchObject(expected); }); it('normalizes a Pleroma logged-out poll', () => { @@ -182,12 +184,10 @@ describe('normalizeStatus()', () => { const poll = result.poll as Poll; // Emojifies poll options - expect(poll.options.get(1)?.title_emojified) + expect(poll.options[1].title_emojified) .toContain('emojione'); - // Parses emojis as Immutable.Record's - expect(ImmutableRecord.isRecord(poll.emojis.get(0))).toBe(true); - expect(poll.emojis.get(1)?.shortcode).toEqual('soapbox'); + expect(poll.emojis[1].shortcode).toEqual('soapbox'); }); it('normalizes a card', () => { @@ -195,7 +195,6 @@ describe('normalizeStatus()', () => { const result = normalizeStatus(status); const card = result.card as Card; - expect(ImmutableRecord.isRecord(card)).toBe(true); expect(card.type).toEqual('link'); expect(card.provider_url).toEqual('https://soapbox.pub'); }); diff --git a/app/soapbox/normalizers/card.ts b/app/soapbox/normalizers/card.ts deleted file mode 100644 index 5d0af42cf..000000000 --- a/app/soapbox/normalizers/card.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Card normalizer: - * Converts API cards into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/card/} - */ -import punycode from 'punycode'; - -import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable'; - -import { groupSchema, type Group } from 'soapbox/schemas'; -import { mergeDefined } from 'soapbox/utils/normalizers'; - -// https://docs.joinmastodon.org/entities/card/ -export const CardRecord = ImmutableRecord({ - author_name: '', - author_url: '', - blurhash: null as string | null, - description: '', - embed_url: '', - group: null as null | Group, - height: 0, - html: '', - image: null as string | null, - provider_name: '', - provider_url: '', - title: '', - type: 'link', - url: '', - width: 0, -}); - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = (domain: string): string => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; - -const getHostname = (url: string): string => { - const parser = document.createElement('a'); - parser.href = url; - return parser.hostname; -}; - -/** Fall back to Pleroma's OG data */ -const normalizePleromaOpengraph = (card: ImmutableMap) => { - const opengraph = ImmutableMap({ - width: card.getIn(['pleroma', 'opengraph', 'width']), - height: card.getIn(['pleroma', 'opengraph', 'height']), - html: card.getIn(['pleroma', 'opengraph', 'html']), - image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']), - }); - - return card.mergeWith(mergeDefined, opengraph); -}; - -/** Set provider from URL if not found */ -const normalizeProviderName = (card: ImmutableMap) => { - const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url'))); - return card.set('provider_name', providerName); -}; - -const normalizeGroup = (card: ImmutableMap) => { - try { - const group = groupSchema.parse(card.get('group').toJS()); - return card.set('group', group); - } catch (_e) { - return card.set('group', null); - } -}; - -export const normalizeCard = (card: Record) => { - return CardRecord( - ImmutableMap(fromJS(card)).withMutations(card => { - normalizePleromaOpengraph(card); - normalizeProviderName(card); - normalizeGroup(card); - }), - ); -}; diff --git a/app/soapbox/normalizers/chat-message.ts b/app/soapbox/normalizers/chat-message.ts index f11a095b9..5ec03bb59 100644 --- a/app/soapbox/normalizers/chat-message.ts +++ b/app/soapbox/normalizers/chat-message.ts @@ -6,8 +6,8 @@ import { } from 'immutable'; import { normalizeAttachment } from 'soapbox/normalizers/attachment'; - -import { normalizeEmojiReaction } from './emoji-reaction'; +import { emojiReactionSchema } from 'soapbox/schemas'; +import { filteredArray } from 'soapbox/schemas/utils'; import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities'; @@ -20,7 +20,7 @@ export const ChatMessageRecord = ImmutableRecord({ created_at: '', emojis: ImmutableList(), expiration: null as number | null, - emoji_reactions: null as ImmutableList | null, + emoji_reactions: null as readonly EmojiReaction[] | null, id: '', unread: false, deleting: false, @@ -41,13 +41,8 @@ const normalizeMedia = (status: ImmutableMap) => { }; const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap) => { - const emojiReactions = chatMessage.get('emoji_reactions'); - - if (emojiReactions) { - return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction))); - } else { - return chatMessage; - } + const emojiReactions = ImmutableList(chatMessage.get('emoji_reactions') || []); + return chatMessage.set('emoji_reactions', filteredArray(emojiReactionSchema).parse(emojiReactions.toJS())); }; /** Rewrite `

` to empty string. */ diff --git a/app/soapbox/normalizers/emoji-reaction.ts b/app/soapbox/normalizers/emoji-reaction.ts deleted file mode 100644 index 88dcfd1e4..000000000 --- a/app/soapbox/normalizers/emoji-reaction.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; - -// https://docs.joinmastodon.org/entities/emoji/ -export const EmojiReactionRecord = ImmutableRecord({ - name: '', - count: null as number | null, - me: false, -}); - -export const normalizeEmojiReaction = (emojiReaction: Record) => { - return EmojiReactionRecord( - ImmutableMap(fromJS(emojiReaction)), - ); -}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 004049988..12bb77d0c 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -4,11 +4,9 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report'; export { AnnouncementRecord, normalizeAnnouncement } from './announcement'; export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction'; export { AttachmentRecord, normalizeAttachment } from './attachment'; -export { CardRecord, normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; -export { EmojiReactionRecord } from './emoji-reaction'; export { FilterRecord, normalizeFilter } from './filter'; export { FilterKeywordRecord, normalizeFilterKeyword } from './filter-keyword'; export { FilterStatusRecord, normalizeFilterStatus } from './filter-status'; @@ -20,11 +18,8 @@ export { ListRecord, normalizeList } from './list'; export { LocationRecord, normalizeLocation } from './location'; export { MentionRecord, normalizeMention } from './mention'; export { NotificationRecord, normalizeNotification } from './notification'; -export { PollRecord, PollOptionRecord, normalizePoll } from './poll'; -export { RelationshipRecord, normalizeRelationship } from './relationship'; export { StatusRecord, normalizeStatus } from './status'; export { StatusEditRecord, normalizeStatusEdit } from './status-edit'; export { TagRecord, normalizeTag } from './tag'; -export { AdRecord, normalizeAd } from './soapbox/ad'; export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox-config'; diff --git a/app/soapbox/normalizers/poll.ts b/app/soapbox/normalizers/poll.ts deleted file mode 100644 index 726278a57..000000000 --- a/app/soapbox/normalizers/poll.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Poll normalizer: - * Converts API polls into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/poll/} - */ -import escapeTextContentForBrowser from 'escape-html'; -import { - Map as ImmutableMap, - List as ImmutableList, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -import emojify from 'soapbox/features/emoji'; -import { normalizeEmoji } from 'soapbox/normalizers/emoji'; -import { makeEmojiMap } from 'soapbox/utils/normalizers'; - -import type { Emoji, PollOption } from 'soapbox/types/entities'; - -// https://docs.joinmastodon.org/entities/poll/ -export const PollRecord = ImmutableRecord({ - emojis: ImmutableList(), - expired: false, - expires_at: '', - id: '', - multiple: false, - options: ImmutableList(), - voters_count: 0, - votes_count: 0, - own_votes: null as ImmutableList | null, - voted: false, - pleroma: ImmutableMap(), -}); - -// Sub-entity of Poll -export const PollOptionRecord = ImmutableRecord({ - title: '', - votes_count: 0, - - // Internal fields - title_emojified: '', -}); - -// Normalize emojis -const normalizeEmojis = (entity: ImmutableMap) => { - return entity.update('emojis', ImmutableList(), emojis => { - return emojis.map(normalizeEmoji); - }); -}; - -const normalizePollOption = (option: ImmutableMap | string, emojis: ImmutableList> = ImmutableList()) => { - const emojiMap = makeEmojiMap(emojis); - - if (typeof option === 'string') { - const titleEmojified = emojify(escapeTextContentForBrowser(option), emojiMap); - - return PollOptionRecord({ - title: option, - title_emojified: titleEmojified, - }); - } - - const titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); - - return PollOptionRecord( - option.set('title_emojified', titleEmojified), - ); -}; - -// Normalize poll options -const normalizePollOptions = (poll: ImmutableMap) => { - const emojis = poll.get('emojis'); - - return poll.update('options', (options: ImmutableList>) => { - return options.map(option => normalizePollOption(option, emojis)); - }); -}; - -// Normalize own_votes to `null` if empty (like Mastodon) -const normalizePollOwnVotes = (poll: ImmutableMap) => { - return poll.update('own_votes', ownVotes => { - return ownVotes?.size > 0 ? ownVotes : null; - }); -}; - -// Whether the user voted in the poll -const normalizePollVoted = (poll: ImmutableMap) => { - return poll.update('voted', voted => { - return typeof voted === 'boolean' ? voted : poll.get('own_votes')?.size > 0; - }); -}; - -export const normalizePoll = (poll: Record) => { - return PollRecord( - ImmutableMap(fromJS(poll)).withMutations((poll: ImmutableMap) => { - normalizeEmojis(poll); - normalizePollOptions(poll); - normalizePollOwnVotes(poll); - normalizePollVoted(poll); - }), - ); -}; diff --git a/app/soapbox/normalizers/relationship.ts b/app/soapbox/normalizers/relationship.ts deleted file mode 100644 index f492a00e9..000000000 --- a/app/soapbox/normalizers/relationship.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Relationship normalizer: - * Converts API relationships into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/relationship/} - */ -import { - Map as ImmutableMap, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -// https://docs.joinmastodon.org/entities/relationship/ -// https://api.pleroma.social/#operation/AccountController.relationships -export const RelationshipRecord = ImmutableRecord({ - blocked_by: false, - blocking: false, - domain_blocking: false, - endorsed: false, - followed_by: false, - following: false, - id: '', - muting: false, - muting_notifications: false, - note: '', - notifying: false, - requested: false, - showing_reblogs: false, - subscribing: false, -}); - -export const normalizeRelationship = (relationship: Record) => { - return RelationshipRecord( - ImmutableMap(fromJS(relationship)), - ); -}; diff --git a/app/soapbox/normalizers/soapbox/ad.ts b/app/soapbox/normalizers/soapbox/ad.ts deleted file mode 100644 index 85dbcc8c6..000000000 --- a/app/soapbox/normalizers/soapbox/ad.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - Map as ImmutableMap, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -import { CardRecord, normalizeCard } from '../card'; - -import type { Ad } from 'soapbox/features/ads/providers'; - -export const AdRecord = ImmutableRecord({ - card: CardRecord(), - impression: undefined as string | undefined, - expires_at: undefined as string | undefined, - reason: undefined as string | undefined, -}); - -/** Normalizes an ad from Soapbox Config. */ -export const normalizeAd = (ad: Record) => { - const map = ImmutableMap(fromJS(ad)); - const card = normalizeCard(map.get('card')); - const expiresAt = map.get('expires_at') || map.get('expires'); - - return AdRecord(map.merge({ - card, - expires_at: expiresAt, - })); -}; diff --git a/app/soapbox/normalizers/soapbox/soapbox-config.ts b/app/soapbox/normalizers/soapbox/soapbox-config.ts index 3b8b2ea91..f73111611 100644 --- a/app/soapbox/normalizers/soapbox/soapbox-config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox-config.ts @@ -6,12 +6,12 @@ import { } from 'immutable'; import trimStart from 'lodash/trimStart'; +import { adSchema } from 'soapbox/schemas'; +import { filteredArray } from 'soapbox/schemas/utils'; import { normalizeUsername } from 'soapbox/utils/input'; import { toTailwind } from 'soapbox/utils/tailwind'; import { generateAccent } from 'soapbox/utils/theme'; -import { normalizeAd } from './ad'; - import type { Ad, PromoPanelItem, @@ -125,8 +125,12 @@ export const SoapboxConfigRecord = ImmutableRecord({ type SoapboxConfigMap = ImmutableMap; const normalizeAds = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { - const ads = ImmutableList>(soapboxConfig.get('ads')); - return soapboxConfig.set('ads', ads.map(normalizeAd)); + if (soapboxConfig.has('ads')) { + const ads = filteredArray(adSchema).parse(soapboxConfig.get('ads').toJS()); + return soapboxConfig.set('ads', ads); + } else { + return soapboxConfig; + } }; const normalizeCryptoAddress = (address: unknown): CryptoAddress => { diff --git a/app/soapbox/normalizers/status-edit.ts b/app/soapbox/normalizers/status-edit.ts index 6f5d8d53a..f569ecce3 100644 --- a/app/soapbox/normalizers/status-edit.ts +++ b/app/soapbox/normalizers/status-edit.ts @@ -12,7 +12,7 @@ import { import emojify from 'soapbox/features/emoji'; import { normalizeAttachment } from 'soapbox/normalizers/attachment'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; -import { normalizePoll } from 'soapbox/normalizers/poll'; +import { pollSchema } from 'soapbox/schemas'; import { stripCompatibilityFeatures } from 'soapbox/utils/html'; import { makeEmojiMap } from 'soapbox/utils/normalizers'; @@ -50,9 +50,10 @@ const normalizeEmojis = (entity: ImmutableMap) => { // Normalize the poll in the status, if applicable const normalizeStatusPoll = (statusEdit: ImmutableMap) => { - if (statusEdit.hasIn(['poll', 'options'])) { - return statusEdit.update('poll', ImmutableMap(), normalizePoll); - } else { + try { + const poll = pollSchema.parse(statusEdit.get('poll').toJS()); + return statusEdit.set('poll', poll); + } catch (_e) { return statusEdit.set('poll', null); } }; diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 7a71f24a8..a207b0434 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -11,10 +11,9 @@ import { } from 'immutable'; import { normalizeAttachment } from 'soapbox/normalizers/attachment'; -import { normalizeCard } from 'soapbox/normalizers/card'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeMention } from 'soapbox/normalizers/mention'; -import { normalizePoll } from 'soapbox/normalizers/poll'; +import { cardSchema, pollSchema, tombstoneSchema } from 'soapbox/schemas'; import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; @@ -37,6 +36,10 @@ export const EventRecord = ImmutableRecord({ links: ImmutableList(), }); +interface Tombstone { + reason: 'deleted' +} + // https://docs.joinmastodon.org/entities/status/ export const StatusRecord = ImmutableRecord({ account: null as EmbeddedEntity, @@ -73,6 +76,7 @@ export const StatusRecord = ImmutableRecord({ sensitive: false, spoiler_text: '', tags: ImmutableList>(), + tombstone: null as Tombstone | null, uri: '', url: '', visibility: 'public' as StatusVisibility, @@ -109,18 +113,29 @@ const normalizeEmojis = (entity: ImmutableMap) => { // Normalize the poll in the status, if applicable const normalizeStatusPoll = (status: ImmutableMap) => { - if (status.hasIn(['poll', 'options'])) { - return status.update('poll', ImmutableMap(), normalizePoll); - } else { + try { + const poll = pollSchema.parse(status.get('poll').toJS()); + return status.set('poll', poll); + } catch (_e) { return status.set('poll', null); } }; +const normalizeTombstone = (status: ImmutableMap) => { + try { + const tombstone = tombstoneSchema.parse(status.get('tombstone').toJS()); + return status.set('tombstone', tombstone); + } catch (_e) { + return status.set('tombstone', null); + } +}; + // Normalize card const normalizeStatusCard = (status: ImmutableMap) => { - if (status.get('card')) { - return status.update('card', ImmutableMap(), normalizeCard); - } else { + try { + const card = cardSchema.parse(status.get('card').toJS()); + return status.set('card', card); + } catch (e) { return status.set('card', null); } }; @@ -245,6 +260,7 @@ export const normalizeStatus = (status: Record) => { fixContent(status); normalizeFilterResults(status); normalizeDislikes(status); + normalizeTombstone(status); }), ); }; diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index e2d6e5755..41d561604 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -123,17 +123,19 @@ const GroupPage: React.FC = ({ params, children }) => { }); } - items.push({ - text: intl.formatMessage(messages.members), - to: `/group/${group?.slug}/members`, - name: '/group/:groupSlug/members', - count: pending.length, - }, - { - text: intl.formatMessage(messages.media), - to: `/group/${group?.slug}/media`, - name: '/group/:groupSlug/media', - }); + items.push( + { + text: intl.formatMessage(messages.media), + to: `/group/${group?.slug}/media`, + name: '/group/:groupSlug/media', + }, + { + text: intl.formatMessage(messages.members), + to: `/group/${group?.slug}/members`, + name: '/group/:groupSlug/members', + count: pending.length, + }, + ); return items; }, [features.groupsTags, pending.length]); diff --git a/app/soapbox/queries/__tests__/chats.test.ts b/app/soapbox/queries/__tests__/chats.test.ts index 65bb6294d..3bcd1b9a7 100644 --- a/app/soapbox/queries/__tests__/chats.test.ts +++ b/app/soapbox/queries/__tests__/chats.test.ts @@ -1,11 +1,11 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap } from 'immutable'; import sumBy from 'lodash/sumBy'; import { useEffect } from 'react'; import { __stub } from 'soapbox/api'; +import { buildRelationship } from 'soapbox/jest/factory'; import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeChatMessage, normalizeRelationship } from 'soapbox/normalizers'; -import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction'; +import { normalizeChatMessage } from 'soapbox/normalizers'; import { Store } from 'soapbox/store'; import { ChatMessage } from 'soapbox/types/entities'; import { flattenPages } from 'soapbox/utils/queries'; @@ -120,7 +120,7 @@ describe('useChatMessages', () => { const state = rootState .set( 'relationships', - ImmutableMap({ '1': normalizeRelationship({ blocked_by: true }) }), + ImmutableMap({ '1': buildRelationship({ blocked_by: true }) }), ); store = mockStore(state); }); @@ -239,7 +239,7 @@ describe('useChat()', () => { mock.onGet(`/api/v1/pleroma/chats/${chat.id}`).reply(200, chat); mock .onGet(`/api/v1/accounts/relationships?id[]=${chat.account.id}`) - .reply(200, [normalizeRelationship({ id: relationshipId, blocked_by: true })]); + .reply(200, [buildRelationship({ id: relationshipId, blocked_by: true })]); }); }); @@ -425,11 +425,11 @@ describe('useChatActions', () => { }); const updatedChatMessage = (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0] as ChatMessage; - expect(updatedChatMessage.emoji_reactions).toEqual(ImmutableList([normalizeEmojiReaction({ + expect(updatedChatMessage.emoji_reactions).toEqual([{ name: '👍', count: 1, me: true, - })])); + }]); }); }); }); diff --git a/app/soapbox/queries/__tests__/relationships.test.ts b/app/soapbox/queries/__tests__/relationships.test.ts index 6466da7ff..02db36166 100644 --- a/app/soapbox/queries/__tests__/relationships.test.ts +++ b/app/soapbox/queries/__tests__/relationships.test.ts @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import { __stub } from 'soapbox/api'; +import { buildRelationship } from 'soapbox/jest/factory'; import { createTestStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeRelationship } from 'soapbox/normalizers'; import { Store } from 'soapbox/store'; import { useFetchRelationships } from '../relationships'; @@ -25,7 +25,7 @@ describe('useFetchRelationships()', () => { __stub((mock) => { mock .onGet(`/api/v1/accounts/relationships?id[]=${id}`) - .reply(200, [normalizeRelationship({ id, blocked_by: true })]); + .reply(200, [buildRelationship({ id, blocked_by: true })]); }); }); @@ -55,7 +55,7 @@ describe('useFetchRelationships()', () => { __stub((mock) => { mock .onGet(`/api/v1/accounts/relationships?id[]=${ids[0]}&id[]=${ids[1]}`) - .reply(200, ids.map((id) => normalizeRelationship({ id, blocked_by: true }))); + .reply(200, ids.map((id) => buildRelationship({ id, blocked_by: true }))); }); }); diff --git a/app/soapbox/queries/ads.ts b/app/soapbox/queries/ads.ts index 722f6a38a..c45bf8b33 100644 --- a/app/soapbox/queries/ads.ts +++ b/app/soapbox/queries/ads.ts @@ -2,7 +2,8 @@ import { useQuery } from '@tanstack/react-query'; import { Ad, getProvider } from 'soapbox/features/ads/providers'; import { useAppDispatch } from 'soapbox/hooks'; -import { normalizeAd } from 'soapbox/normalizers'; +import { adSchema } from 'soapbox/schemas'; +import { filteredArray } from 'soapbox/schemas/utils'; import { isExpired } from 'soapbox/utils/ads'; const AdKeys = { @@ -28,7 +29,9 @@ function useAds() { }); // Filter out expired ads. - const data = result.data?.map(normalizeAd).filter(ad => !isExpired(ad)); + const data = filteredArray(adSchema) + .parse(result.data) + .filter(ad => !isExpired(ad)); return { ...result, diff --git a/app/soapbox/reducers/__tests__/polls.test.ts b/app/soapbox/reducers/__tests__/polls.test.ts index b9ceb07f7..74627fb68 100644 --- a/app/soapbox/reducers/__tests__/polls.test.ts +++ b/app/soapbox/reducers/__tests__/polls.test.ts @@ -11,14 +11,17 @@ describe('polls reducer', () => { describe('POLLS_IMPORT', () => { it('normalizes the poll', () => { - const polls = [{ id: '3', options: [{ title: 'Apples' }] }]; + const polls = [{ id: '3', options: [{ title: 'Apples' }, { title: 'Oranges' }] }]; const action = { type: POLLS_IMPORT, polls }; const result = reducer(undefined, action); const expected = { '3': { - options: [{ title: 'Apples', votes_count: 0 }], + options: [ + { title: 'Apples', votes_count: 0 }, + { title: 'Oranges', votes_count: 0 }, + ], emojis: [], expired: false, multiple: false, diff --git a/app/soapbox/reducers/relationships.ts b/app/soapbox/reducers/relationships.ts index 2eb035ec4..40d062f78 100644 --- a/app/soapbox/reducers/relationships.ts +++ b/app/soapbox/reducers/relationships.ts @@ -2,7 +2,7 @@ import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import get from 'lodash/get'; import { STREAMING_FOLLOW_RELATIONSHIPS_UPDATE } from 'soapbox/actions/streaming'; -import { normalizeRelationship } from 'soapbox/normalizers/relationship'; +import { type Relationship, relationshipSchema } from 'soapbox/schemas'; import { ACCOUNT_NOTE_SUBMIT_SUCCESS } from '../actions/account-notes'; import { @@ -35,13 +35,16 @@ import { import type { AnyAction } from 'redux'; import type { APIEntity } from 'soapbox/types/entities'; -type Relationship = ReturnType; type State = ImmutableMap; type APIEntities = Array; const normalizeRelationships = (state: State, relationships: APIEntities) => { relationships.forEach(relationship => { - state = state.set(relationship.id, normalizeRelationship(relationship)); + try { + state = state.set(relationship.id, relationshipSchema.parse(relationship)); + } catch (_e) { + // do nothing + } }); return state; @@ -84,8 +87,12 @@ const followStateToRelationship = (followState: string) => { }; const updateFollowRelationship = (state: State, id: string, followState: string) => { - const map = followStateToRelationship(followState); - return state.update(id, normalizeRelationship({}), relationship => relationship.merge(map)); + const relationship = state.get(id) || relationshipSchema.parse({ id }); + + return state.set(id, { + ...relationship, + ...followStateToRelationship(followState), + }); }; export default function relationships(state: State = ImmutableMap(), action: AnyAction) { diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 3ae9c308f..d8c91b6a3 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -74,11 +74,11 @@ const minifyStatus = (status: StatusRecord): ReducerStatus => { }; // Gets titles of poll options from status -const getPollOptionTitles = ({ poll }: StatusRecord): ImmutableList => { +const getPollOptionTitles = ({ poll }: StatusRecord): readonly string[] => { if (poll && typeof poll === 'object') { return poll.options.map(({ title }) => title); } else { - return ImmutableList(); + return []; } }; diff --git a/app/soapbox/rtl.ts b/app/soapbox/rtl.ts index 4c3599cb6..16a6100ef 100644 --- a/app/soapbox/rtl.ts +++ b/app/soapbox/rtl.ts @@ -19,7 +19,12 @@ export function isRtl(text: string): boolean { if (text.length === 0) { return false; } - + // Remove http(s), (s)ftp, ws(s), blob and smtp(s) links + text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, ''); + // Remove email address links + text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, ''); + // Remove Phone numbe links + text = text.replace(/(tel:)([+\d\s()-]+)/g, ''); text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); text = text.replace(/\s+/g, ''); @@ -27,6 +32,7 @@ export function isRtl(text: string): boolean { const matches = text.match(rtlChars); if (!matches) { + return false; } diff --git a/app/soapbox/schemas/__tests__/card.test.ts b/app/soapbox/schemas/__tests__/card.test.ts new file mode 100644 index 000000000..d66730c21 --- /dev/null +++ b/app/soapbox/schemas/__tests__/card.test.ts @@ -0,0 +1,11 @@ +import { cardSchema } from '../card'; + +describe('cardSchema', () => { + it('adds base fields', () => { + const card = { url: 'https://soapbox.test' }; + const result = cardSchema.parse(card); + + expect(result.type).toEqual('link'); + expect(result.url).toEqual(card.url); + }); +}); diff --git a/app/soapbox/schemas/__tests__/poll.test.ts b/app/soapbox/schemas/__tests__/poll.test.ts new file mode 100644 index 000000000..fe39315dd --- /dev/null +++ b/app/soapbox/schemas/__tests__/poll.test.ts @@ -0,0 +1,44 @@ +import { pollSchema } from '../poll'; + +describe('normalizePoll()', () => { + it('adds base fields', () => { + const poll = { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] }; + const result = pollSchema.parse(poll); + + const expected = { + options: [ + { title: 'Apples', votes_count: 0 }, + { title: 'Oranges', votes_count: 0 }, + ], + emojis: [], + expired: false, + multiple: false, + voters_count: 0, + votes_count: 0, + own_votes: null, + voted: false, + }; + + expect(result).toMatchObject(expected); + }); + + it('normalizes a Pleroma logged-out poll', () => { + const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll.json'); + const result = pollSchema.parse(poll); + + // Adds logged-in fields + expect(result.voted).toBe(false); + expect(result.own_votes).toBe(null); + }); + + it('normalizes poll with emojis', () => { + const { poll } = require('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json'); + const result = pollSchema.parse(poll); + + // Emojifies poll options + expect(result.options[1]?.title_emojified) + .toContain('emojione'); + + expect(result.emojis[1]?.shortcode).toEqual('soapbox'); + }); +}); diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index d8eb7f79c..919013329 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -5,7 +5,7 @@ import emojify from 'soapbox/features/emoji'; import { customEmojiSchema } from './custom-emoji'; import { relationshipSchema } from './relationship'; -import { filteredArray, makeCustomEmojiMap } from './utils'; +import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); @@ -22,7 +22,7 @@ const accountSchema = z.object({ created_at: z.string().datetime().catch(new Date().toUTCString()), discoverable: z.boolean().catch(false), display_name: z.string().catch(''), - emojis: filteredArray(customEmojiSchema).catch([]), + emojis: filteredArray(customEmojiSchema), favicon: z.string().catch(''), fields: z.any(), // TODO followers_count: z.number().catch(0), @@ -39,12 +39,12 @@ const accountSchema = z.object({ z.string(), z.null(), ]).catch(null), - note: z.string().catch(''), + note: contentSchema, pleroma: z.any(), // TODO source: z.any(), // TODO statuses_count: z.number().catch(0), - uri: z.string().catch(''), - url: z.string().catch(''), + uri: z.string().url().catch(''), + url: z.string().url().catch(''), username: z.string().catch(''), verified: z.boolean().default(false), website: z.string().catch(''), @@ -121,4 +121,4 @@ const accountSchema = z.object({ type Account = z.infer; -export { accountSchema, Account }; \ No newline at end of file +export { accountSchema, type Account }; \ No newline at end of file diff --git a/app/soapbox/schemas/attachment.ts b/app/soapbox/schemas/attachment.ts new file mode 100644 index 000000000..44b9cb126 --- /dev/null +++ b/app/soapbox/schemas/attachment.ts @@ -0,0 +1,89 @@ +import { isBlurhashValid } from 'blurhash'; +import { z } from 'zod'; + +const blurhashSchema = z.string().superRefine((value, ctx) => { + const r = isBlurhashValid(value); + + if (!r.result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: r.errorReason, + }); + } +}); + +const baseAttachmentSchema = z.object({ + blurhash: blurhashSchema.nullable().catch(null), + description: z.string().catch(''), + external_video_id: z.string().optional().catch(undefined), // TruthSocial + id: z.string(), + pleroma: z.object({ + mime_type: z.string().regex(/^\w+\/[-+.\w]+$/), + }).optional().catch(undefined), + preview_url: z.string().url().catch(''), + remote_url: z.string().url().nullable().catch(null), + type: z.string(), + url: z.string().url(), +}); + +const imageMetaSchema = z.object({ + width: z.number(), + height: z.number(), + aspect: z.number().optional().catch(undefined), +}).transform((meta) => ({ + ...meta, + aspect: typeof meta.aspect === 'number' ? meta.aspect : meta.width / meta.height, +})); + +const imageAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('image'), + meta: z.object({ + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const videoAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('video'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const gifvAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('gifv'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const audioAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('audio'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + }).catch({}), +}); + +const unknownAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('unknown'), +}); + +/** https://docs.joinmastodon.org/entities/attachment */ +const attachmentSchema = z.discriminatedUnion('type', [ + imageAttachmentSchema, + videoAttachmentSchema, + gifvAttachmentSchema, + audioAttachmentSchema, + unknownAttachmentSchema, +]).transform((attachment) => { + if (!attachment.preview_url) { + attachment.preview_url = attachment.url; + } + + return attachment; +}); + +type Attachment = z.infer; + +export { attachmentSchema, type Attachment }; \ No newline at end of file diff --git a/app/soapbox/schemas/card.ts b/app/soapbox/schemas/card.ts new file mode 100644 index 000000000..d35c9f109 --- /dev/null +++ b/app/soapbox/schemas/card.ts @@ -0,0 +1,69 @@ +import punycode from 'punycode'; + +import { z } from 'zod'; + +import { groupSchema } from './group'; + +const IDNA_PREFIX = 'xn--'; + +/** + * Card (aka link preview). + * https://docs.joinmastodon.org/entities/card/ + */ +const cardSchema = z.object({ + author_name: z.string().catch(''), + author_url: z.string().url().catch(''), + blurhash: z.string().nullable().catch(null), + description: z.string().catch(''), + embed_url: z.string().url().catch(''), + group: groupSchema.nullable().catch(null), // TruthSocial + height: z.number().catch(0), + html: z.string().catch(''), + image: z.string().nullable().catch(null), + pleroma: z.object({ + opengraph: z.object({ + width: z.number(), + height: z.number(), + html: z.string(), + thumbnail_url: z.string().url(), + }).optional().catch(undefined), + }).optional().catch(undefined), + provider_name: z.string().catch(''), + provider_url: z.string().url().catch(''), + title: z.string().catch(''), + type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'), + url: z.string().url(), + width: z.number().catch(0), +}).transform(({ pleroma, ...card }) => { + if (!card.provider_name) { + card.provider_name = decodeIDNA(new URL(card.url).hostname); + } + + if (pleroma?.opengraph) { + if (!card.width && !card.height) { + card.width = pleroma.opengraph.width; + card.height = pleroma.opengraph.height; + } + + if (!card.html) { + card.html = pleroma.opengraph.html; + } + + if (!card.image) { + card.image = pleroma.opengraph.thumbnail_url; + } + } + + return card; +}); + +const decodeIDNA = (domain: string): string => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; + +type Card = z.infer; + +export { cardSchema, type Card }; \ No newline at end of file diff --git a/app/soapbox/schemas/chat-message.ts b/app/soapbox/schemas/chat-message.ts new file mode 100644 index 000000000..fe0ff0f6b --- /dev/null +++ b/app/soapbox/schemas/chat-message.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { attachmentSchema } from './attachment'; +import { cardSchema } from './card'; +import { customEmojiSchema } from './custom-emoji'; +import { contentSchema, emojiSchema, filteredArray } from './utils'; + +const chatMessageSchema = z.object({ + account_id: z.string(), + media_attachments: filteredArray(attachmentSchema), + card: cardSchema.nullable().catch(null), + chat_id: z.string(), + content: contentSchema, + created_at: z.string().datetime().catch(new Date().toUTCString()), + emojis: filteredArray(customEmojiSchema), + expiration: z.number().optional().catch(undefined), + emoji_reactions: z.array(emojiSchema).min(1).nullable().catch(null), + id: z.string(), + unread: z.coerce.boolean(), + deleting: z.coerce.boolean(), + pending: z.coerce.boolean(), +}); + +type ChatMessage = z.infer; + +export { chatMessageSchema, type ChatMessage }; \ No newline at end of file diff --git a/app/soapbox/schemas/custom-emoji.ts b/app/soapbox/schemas/custom-emoji.ts index 68c49c587..1addf026a 100644 --- a/app/soapbox/schemas/custom-emoji.ts +++ b/app/soapbox/schemas/custom-emoji.ts @@ -14,4 +14,4 @@ const customEmojiSchema = z.object({ type CustomEmoji = z.infer; -export { customEmojiSchema, CustomEmoji }; +export { customEmojiSchema, type CustomEmoji }; diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts new file mode 100644 index 000000000..1559148e1 --- /dev/null +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { emojiSchema } from './utils'; + +/** Pleroma emoji reaction. */ +const emojiReactionSchema = z.object({ + name: emojiSchema, + count: z.number().nullable().catch(null), + me: z.boolean().catch(false), +}); + +type EmojiReaction = z.infer; + +export { emojiReactionSchema, type EmojiReaction }; \ No newline at end of file diff --git a/app/soapbox/schemas/group-member.ts b/app/soapbox/schemas/group-member.ts index 4521450cb..8135fecb6 100644 --- a/app/soapbox/schemas/group-member.ts +++ b/app/soapbox/schemas/group-member.ts @@ -16,4 +16,4 @@ const groupMemberSchema = z.object({ type GroupMember = z.infer; -export { groupMemberSchema, GroupMember, GroupRoles }; \ No newline at end of file +export { groupMemberSchema, type GroupMember, GroupRoles }; \ No newline at end of file diff --git a/app/soapbox/schemas/group-relationship.ts b/app/soapbox/schemas/group-relationship.ts index 5bf4cae31..baeb55a12 100644 --- a/app/soapbox/schemas/group-relationship.ts +++ b/app/soapbox/schemas/group-relationship.ts @@ -14,4 +14,4 @@ const groupRelationshipSchema = z.object({ type GroupRelationship = z.infer; -export { groupRelationshipSchema, GroupRelationship }; \ No newline at end of file +export { groupRelationshipSchema, type GroupRelationship }; \ No newline at end of file diff --git a/app/soapbox/schemas/group-tag.ts b/app/soapbox/schemas/group-tag.ts index f49162cfd..0d07003ff 100644 --- a/app/soapbox/schemas/group-tag.ts +++ b/app/soapbox/schemas/group-tag.ts @@ -5,6 +5,7 @@ const groupTagSchema = z.object({ name: z.string(), groups: z.number().optional(), url: z.string().optional(), + uses: z.number().optional(), pinned: z.boolean().optional().catch(false), visible: z.boolean().optional().default(true), }); diff --git a/app/soapbox/schemas/group.ts b/app/soapbox/schemas/group.ts index d627ace22..d5ee7f2ee 100644 --- a/app/soapbox/schemas/group.ts +++ b/app/soapbox/schemas/group.ts @@ -19,7 +19,7 @@ const groupSchema = z.object({ deleted_at: z.string().datetime().or(z.null()).catch(null), display_name: z.string().catch(''), domain: z.string().catch(''), - emojis: filteredArray(customEmojiSchema).catch([]), + emojis: filteredArray(customEmojiSchema), group_visibility: z.string().catch(''), // TruthSocial header: z.string().catch(headerMissing), header_static: z.string().catch(''), diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index a675b52d2..1fa8ac5b4 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -1,21 +1,20 @@ -/** - * Schemas - */ -export { accountSchema } from './account'; -export { customEmojiSchema } from './custom-emoji'; -export { groupSchema } from './group'; -export { groupMemberSchema } from './group-member'; -export { groupRelationshipSchema } from './group-relationship'; -export { groupTagSchema } from './group-tag'; -export { relationshipSchema } from './relationship'; +export { accountSchema, type Account } from './account'; +export { attachmentSchema, type Attachment } from './attachment'; +export { cardSchema, type Card } from './card'; +export { chatMessageSchema, type ChatMessage } from './chat-message'; +export { customEmojiSchema, type CustomEmoji } from './custom-emoji'; +export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction'; +export { groupSchema, type Group } from './group'; +export { groupMemberSchema, type GroupMember } from './group-member'; +export { groupRelationshipSchema, type GroupRelationship } from './group-relationship'; +export { groupTagSchema, type GroupTag } from './group-tag'; +export { mentionSchema, type Mention } from './mention'; +export { notificationSchema, type Notification } from './notification'; +export { pollSchema, type Poll, type PollOption } from './poll'; +export { relationshipSchema, type Relationship } from './relationship'; +export { statusSchema, type Status } from './status'; +export { tagSchema, type Tag } from './tag'; +export { tombstoneSchema, type Tombstone } from './tombstone'; -/** - * Entity Types - */ -export type { Account } from './account'; -export type { CustomEmoji } from './custom-emoji'; -export type { Group } from './group'; -export type { GroupMember } from './group-member'; -export type { GroupRelationship } from './group-relationship'; -export type { GroupTag } from './group-tag'; -export type { Relationship } from './relationship'; +// Soapbox +export { adSchema, type Ad } from './soapbox/ad'; \ No newline at end of file diff --git a/app/soapbox/schemas/mention.ts b/app/soapbox/schemas/mention.ts new file mode 100644 index 000000000..9bbdbff5b --- /dev/null +++ b/app/soapbox/schemas/mention.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const mentionSchema = z.object({ + acct: z.string(), + id: z.string(), + url: z.string().url().catch(''), + username: z.string().catch(''), +}).transform((mention) => { + if (!mention.username) { + mention.username = mention.acct.split('@')[0]; + } + + return mention; +}); + +type Mention = z.infer; + +export { mentionSchema, type Mention }; \ No newline at end of file diff --git a/app/soapbox/schemas/notification.ts b/app/soapbox/schemas/notification.ts new file mode 100644 index 000000000..3c77de6bf --- /dev/null +++ b/app/soapbox/schemas/notification.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +import { accountSchema } from './account'; +import { chatMessageSchema } from './chat-message'; +import { statusSchema } from './status'; +import { emojiSchema } from './utils'; + +const baseNotificationSchema = z.object({ + account: accountSchema, + created_at: z.string().datetime().catch(new Date().toUTCString()), + id: z.string(), + type: z.string(), + total_count: z.number().optional().catch(undefined), // TruthSocial +}); + +const mentionNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('mention'), + status: statusSchema, +}); + +const statusNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('status'), + status: statusSchema, +}); + +const reblogNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('reblog'), + status: statusSchema, +}); + +const followNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('follow'), +}); + +const followRequestNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('follow_request'), +}); + +const favouriteNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('favourite'), + status: statusSchema, +}); + +const pollNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('poll'), + status: statusSchema, +}); + +const updateNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('update'), + status: statusSchema, +}); + +const moveNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('move'), + target: accountSchema, +}); + +const chatMessageNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('chat_message'), + chat_message: chatMessageSchema, +}); + +const emojiReactionNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:emoji_reaction'), + emoji: emojiSchema, + emoji_url: z.string().url().optional().catch(undefined), +}); + +const eventReminderNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:event_reminder'), + status: statusSchema, +}); + +const participationRequestNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:participation_request'), + status: statusSchema, +}); + +const participationAcceptedNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:participation_accepted'), + status: statusSchema, +}); + +const notificationSchema = z.discriminatedUnion('type', [ + mentionNotificationSchema, + statusNotificationSchema, + reblogNotificationSchema, + followNotificationSchema, + followRequestNotificationSchema, + favouriteNotificationSchema, + pollNotificationSchema, + updateNotificationSchema, + moveNotificationSchema, + chatMessageNotificationSchema, + emojiReactionNotificationSchema, + eventReminderNotificationSchema, + participationRequestNotificationSchema, + participationAcceptedNotificationSchema, +]); + +type Notification = z.infer; + +export { notificationSchema, type Notification }; \ No newline at end of file diff --git a/app/soapbox/schemas/poll.ts b/app/soapbox/schemas/poll.ts new file mode 100644 index 000000000..73d27753a --- /dev/null +++ b/app/soapbox/schemas/poll.ts @@ -0,0 +1,50 @@ +import escapeTextContentForBrowser from 'escape-html'; +import { z } from 'zod'; + +import emojify from 'soapbox/features/emoji'; + +import { customEmojiSchema } from './custom-emoji'; +import { filteredArray, makeCustomEmojiMap } from './utils'; + +const pollOptionSchema = z.object({ + title: z.string().catch(''), + votes_count: z.number().catch(0), +}); + +const pollSchema = z.object({ + emojis: filteredArray(customEmojiSchema), + expired: z.boolean().catch(false), + expires_at: z.string().datetime().catch(new Date().toUTCString()), + id: z.string(), + multiple: z.boolean().catch(false), + options: z.array(pollOptionSchema).min(2), + voters_count: z.number().catch(0), + votes_count: z.number().catch(0), + own_votes: z.array(z.number()).nonempty().nullable().catch(null), + voted: z.boolean().catch(false), + pleroma: z.object({ + non_anonymous: z.boolean().catch(false), + }).optional().catch(undefined), +}).transform((poll) => { + const emojiMap = makeCustomEmojiMap(poll.emojis); + + const emojifiedOptions = poll.options.map((option) => ({ + ...option, + title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), + })); + + // If the user has votes, they have certainly voted. + if (poll.own_votes?.length) { + poll.voted = true; + } + + return { + ...poll, + options: emojifiedOptions, + }; +}); + +type Poll = z.infer; +type PollOption = Poll['options'][number]; + +export { pollSchema, type Poll, type PollOption }; \ No newline at end of file diff --git a/app/soapbox/schemas/relationship.ts b/app/soapbox/schemas/relationship.ts index 7d1e109c8..003cf747a 100644 --- a/app/soapbox/schemas/relationship.ts +++ b/app/soapbox/schemas/relationship.ts @@ -19,4 +19,4 @@ const relationshipSchema = z.object({ type Relationship = z.infer; -export { relationshipSchema, Relationship }; \ No newline at end of file +export { relationshipSchema, type Relationship }; \ No newline at end of file diff --git a/app/soapbox/schemas/soapbox/ad.ts b/app/soapbox/schemas/soapbox/ad.ts new file mode 100644 index 000000000..40dc05fb3 --- /dev/null +++ b/app/soapbox/schemas/soapbox/ad.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { cardSchema } from '../card'; + +const adSchema = z.object({ + card: cardSchema, + impression: z.string().optional().catch(undefined), + expires_at: z.string().datetime().optional().catch(undefined), + reason: z.string().optional().catch(undefined), +}); + +type Ad = z.infer; + +export { adSchema, type Ad }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index 66d6f05eb..ea55d5085 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -1,9 +1,65 @@ import { z } from 'zod'; -import { normalizeStatus } from 'soapbox/normalizers'; -import { toSchema } from 'soapbox/utils/normalizers'; +import { accountSchema } from './account'; +import { attachmentSchema } from './attachment'; +import { cardSchema } from './card'; +import { customEmojiSchema } from './custom-emoji'; +import { groupSchema } from './group'; +import { mentionSchema } from './mention'; +import { pollSchema } from './poll'; +import { tagSchema } from './tag'; +import { contentSchema, dateSchema, filteredArray } from './utils'; -const statusSchema = toSchema(normalizeStatus); +const tombstoneSchema = z.object({ + reason: z.enum(['deleted']), +}); + +const baseStatusSchema = z.object({ + account: accountSchema, + application: z.object({ + name: z.string(), + website: z.string().url().nullable().catch(null), + }).nullable().catch(null), + bookmarked: z.coerce.boolean(), + card: cardSchema.nullable().catch(null), + content: contentSchema, + created_at: dateSchema, + disliked: z.coerce.boolean(), + dislikes_count: z.number().catch(0), + edited_at: z.string().datetime().nullable().catch(null), + emojis: filteredArray(customEmojiSchema), + favourited: z.coerce.boolean(), + favourites_count: z.number().catch(0), + group: groupSchema.nullable().catch(null), + in_reply_to_account_id: z.string().nullable().catch(null), + in_reply_to_id: z.string().nullable().catch(null), + id: z.string(), + language: z.string().nullable().catch(null), + media_attachments: filteredArray(attachmentSchema), + mentions: filteredArray(mentionSchema), + muted: z.coerce.boolean(), + pinned: z.coerce.boolean(), + pleroma: z.object({}).optional().catch(undefined), + poll: pollSchema.nullable().catch(null), + quote: z.literal(null).catch(null), + quotes_count: z.number().catch(0), + reblog: z.literal(null).catch(null), + reblogged: z.coerce.boolean(), + reblogs_count: z.number().catch(0), + replies_count: z.number().catch(0), + sensitive: z.coerce.boolean(), + spoiler_text: contentSchema, + tags: filteredArray(tagSchema), + tombstone: tombstoneSchema.nullable().optional(), + uri: z.string().url().catch(''), + url: z.string().url().catch(''), + visibility: z.string().catch('public'), +}); + +const statusSchema = baseStatusSchema.extend({ + quote: baseStatusSchema.nullable().catch(null), + reblog: baseStatusSchema.nullable().catch(null), +}); type Status = z.infer; diff --git a/app/soapbox/schemas/tag.ts b/app/soapbox/schemas/tag.ts new file mode 100644 index 000000000..22e903d60 --- /dev/null +++ b/app/soapbox/schemas/tag.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const historySchema = z.object({ + accounts: z.coerce.number(), + uses: z.coerce.number(), +}); + +/** https://docs.joinmastodon.org/entities/tag */ +const tagSchema = z.object({ + name: z.string().min(1), + url: z.string().url().catch(''), + history: z.array(historySchema).nullable().catch(null), + following: z.boolean().catch(false), +}); + +type Tag = z.infer; + +export { tagSchema, type Tag }; \ No newline at end of file diff --git a/app/soapbox/schemas/tombstone.ts b/app/soapbox/schemas/tombstone.ts new file mode 100644 index 000000000..e7617a27a --- /dev/null +++ b/app/soapbox/schemas/tombstone.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +const tombstoneSchema = z.object({ + reason: z.enum(['deleted']), +}); + +type Tombstone = z.infer; + +export { tombstoneSchema, type Tombstone }; \ No newline at end of file diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index 72f5f49d9..c85b2b2b1 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -2,9 +2,15 @@ import z from 'zod'; import type { CustomEmoji } from './custom-emoji'; +/** Ensure HTML content is a string, and drop empty `

` tags. */ +const contentSchema = z.string().catch('').transform((value) => value === '

' ? '' : value); + +/** Validate to Mastodon's date format, or use the current date. */ +const dateSchema = z.string().datetime().catch(new Date().toUTCString()); + /** Validates individual items in an array, dropping any that aren't valid. */ function filteredArray(schema: T) { - return z.any().array() + return z.any().array().catch([]) .transform((arr) => ( arr.map((item) => { const parsed = schema.safeParse(item); @@ -13,6 +19,9 @@ function filteredArray(schema: T) { )); } +/** Validates the string as an emoji. */ +const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)); + /** Map a list of CustomEmoji to their shortcodes. */ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { return customEmojis.reduce>((result, emoji) => { @@ -21,4 +30,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -export { filteredArray, makeCustomEmojiMap }; \ No newline at end of file +export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema }; \ No newline at end of file diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index c4d8907c5..712a89e23 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -5,11 +5,9 @@ import { AnnouncementRecord, AnnouncementReactionRecord, AttachmentRecord, - CardRecord, ChatRecord, ChatMessageRecord, EmojiRecord, - EmojiReactionRecord, FieldRecord, FilterRecord, FilterKeywordRecord, @@ -20,9 +18,6 @@ import { LocationRecord, MentionRecord, NotificationRecord, - PollRecord, - PollOptionRecord, - RelationshipRecord, StatusEditRecord, StatusRecord, TagRecord, @@ -37,11 +32,9 @@ type AdminReport = ReturnType; type Announcement = ReturnType; type AnnouncementReaction = ReturnType; type Attachment = ReturnType; -type Card = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; -type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; type FilterKeyword = ReturnType; @@ -52,9 +45,6 @@ type List = ReturnType; type Location = ReturnType; type Mention = ReturnType; type Notification = ReturnType; -type Poll = ReturnType; -type PollOption = ReturnType; -type Relationship = ReturnType; type StatusEdit = ReturnType; type Tag = ReturnType; @@ -82,11 +72,9 @@ export { Announcement, AnnouncementReaction, Attachment, - Card, Chat, ChatMessage, Emoji, - EmojiReaction, Field, Filter, FilterKeyword, @@ -97,9 +85,6 @@ export { Location, Mention, Notification, - Poll, - PollOption, - Relationship, Status, StatusEdit, Tag, @@ -110,7 +95,12 @@ export { }; export type { + Card, + EmojiReaction, Group, GroupMember, GroupRelationship, + Poll, + PollOption, + Relationship, } from 'soapbox/schemas'; \ No newline at end of file diff --git a/app/soapbox/types/soapbox.ts b/app/soapbox/types/soapbox.ts index 3b8b247a6..e9baff635 100644 --- a/app/soapbox/types/soapbox.ts +++ b/app/soapbox/types/soapbox.ts @@ -1,4 +1,3 @@ -import { AdRecord } from 'soapbox/normalizers/soapbox/ad'; import { PromoPanelItemRecord, FooterItemRecord, @@ -8,7 +7,6 @@ import { type Me = string | null | false | undefined; -type Ad = ReturnType; type PromoPanelItem = ReturnType; type FooterItem = ReturnType; type CryptoAddress = ReturnType; @@ -16,9 +14,12 @@ type SoapboxConfig = ReturnType; export { Me, - Ad, PromoPanelItem, FooterItem, CryptoAddress, SoapboxConfig, }; + +export type { + Ad, +} from 'soapbox/schemas'; \ No newline at end of file diff --git a/app/soapbox/utils/__tests__/ads.test.ts b/app/soapbox/utils/__tests__/ads.test.ts index f96f29936..5ceb9d45b 100644 --- a/app/soapbox/utils/__tests__/ads.test.ts +++ b/app/soapbox/utils/__tests__/ads.test.ts @@ -1,4 +1,4 @@ -import { normalizeAd } from 'soapbox/normalizers'; +import { buildAd } from 'soapbox/jest/factory'; import { isExpired } from '../ads'; @@ -14,10 +14,10 @@ test('isExpired()', () => { const epoch = now.getTime(); // Sanity tests. - expect(isExpired(normalizeAd({ expires_at: iso }))).toBe(true); - expect(isExpired(normalizeAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false); + expect(isExpired(buildAd({ expires_at: iso }))).toBe(true); + expect(isExpired(buildAd({ expires_at: new Date(epoch + 999999).toISOString() }))).toBe(false); // Testing the 5-minute mark. - expect(isExpired(normalizeAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true); - expect(isExpired(normalizeAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false); + expect(isExpired(buildAd({ expires_at: new Date(epoch + threeMins).toISOString() }), fiveMins)).toBe(true); + expect(isExpired(buildAd({ expires_at: new Date(epoch + fiveMins + 1000).toISOString() }), fiveMins)).toBe(false); }); diff --git a/app/soapbox/utils/ads.ts b/app/soapbox/utils/ads.ts index 949315191..b59cf430b 100644 --- a/app/soapbox/utils/ads.ts +++ b/app/soapbox/utils/ads.ts @@ -1,4 +1,4 @@ -import type { Ad } from 'soapbox/types/soapbox'; +import type { Ad } from 'soapbox/schemas'; /** Time (ms) window to not display an ad if it's about to expire. */ const AD_EXPIRY_THRESHOLD = 5 * 60 * 1000; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 9fd16017d..be3650fc1 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -16,6 +16,12 @@ const overrides = custom('features'); /** Truthy array convenience function */ const any = (arr: Array): boolean => arr.some(Boolean); +/** + * Ditto, a Nostr server with Mastodon API. + * @see {@link https://gitlab.com/soapbox-pub/ditto} + */ +export const DITTO = 'Ditto'; + /** * Friendica, decentralized social platform implementing multiple federation protocols. * @see {@link https://friendi.ca/} @@ -137,6 +143,7 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA && gte(v.version, '2.4.50'), v.software === TAKAHE && gte(v.version, '0.6.1'), v.software === TRUTHSOCIAL, + v.software === DITTO, ]), /** @@ -254,7 +261,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Ability to add reactions to chat messages. */ - chatEmojiReactions: v.software === TRUTHSOCIAL && v.build === UNRELEASED, + chatEmojiReactions: v.software === TRUTHSOCIAL, /** * Pleroma chats API. diff --git a/app/styles/components/detailed-status.scss b/app/styles/components/detailed-status.scss index d07c31814..daa7296ee 100644 --- a/app/styles/components/detailed-status.scss +++ b/app/styles/components/detailed-status.scss @@ -12,14 +12,4 @@ .status__content-wrapper { @apply pl-[calc(42px+12px)] rtl:pl-0 rtl:pr-[calc(42px+12px)]; } - - &__connector { - @apply bg-gray-200 dark:bg-primary-800 absolute w-0.5 left-5 hidden z-[1] rtl:right-5 rtl:left-auto; - - &--bottom { - @apply block; - height: calc(100% - 42px - 8px - 1rem); - top: calc(12px + 42px); - } - } } diff --git a/package.json b/package.json index a63b469fb..99b79a449 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.20.13", "@emoji-mart/data": "^1.1.2", - "@floating-ui/react": "^0.23.0", + "@floating-ui/react": "^0.24.0", "@fontsource/inter": "^4.5.1", "@fontsource/roboto-mono": "^4.5.8", "@gamestdio/websocket": "^0.3.2", diff --git a/yarn.lock b/yarn.lock index 0750088a0..c7e6e38e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1732,31 +1732,31 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@floating-ui/core@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.2.tgz#66f62cf1b7de2ed23a09c101808536e68caffaec" - integrity sha512-FaO9KVLFnxknZaGWGmNtjD2CVFuc0u4yeGEofoyXO2wgRA7fLtkngT6UB0vtWQWuhH3iMTZZ/Y89CMeyGfn8pA== +"@floating-ui/core@^1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0" + integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg== -"@floating-ui/dom@^1.2.1": - version "1.2.3" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.3.tgz#8dc6fbf799fbb5c29f705b54bdd51f3ab0ee03a2" - integrity sha512-lK9cZUrHSJLMVAdCvDqs6Ug8gr0wmqksYiaoj/bxj2gweRQkSuhg2/V6Jswz2KiQ0RAULbqw1oQDJIMpQ5GfGA== +"@floating-ui/dom@^1.2.7": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.7.tgz#c123e4db014b07b97e996cd459245fa217049c6b" + integrity sha512-DyqylONj1ZaBnzj+uBnVfzdjjCkFCL2aA9ESHLyUOGSqb03RpbLMImP1ekIQXYs4KLk9jAjJfZAU8hXfWSahEg== dependencies: - "@floating-ui/core" "^1.2.2" + "@floating-ui/core" "^1.2.6" -"@floating-ui/react-dom@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.3.0.tgz#4d35d416eb19811c2b0e9271100a6aa18c1579b3" - integrity sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g== +"@floating-ui/react-dom@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.0.tgz#7514baac526c818892bbcc84e1c3115008c029f9" + integrity sha512-Ke0oU3SeuABC2C4OFu2mSAwHIP5WUiV98O9YWoHV4Q5aT6E9k06DV0Khi5uYspR8xmmBk08t8ZDcz3TR3ARkEg== dependencies: - "@floating-ui/dom" "^1.2.1" + "@floating-ui/dom" "^1.2.7" -"@floating-ui/react@^0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.23.0.tgz#8b548235ac4478537757c90a66a3bac9068e29d8" - integrity sha512-Id9zTLSjHtcCjBQm0Stc/fRUBGrnHurL/a1HrtQg8LvL6Ciw9KHma2WT++F17kEfhsPkA0UHYxmp+ijmAy0TCw== +"@floating-ui/react@^0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.24.0.tgz#d835f7041b9b4ed1b7daa83f33af412ae0c6c946" + integrity sha512-/UxcKFV5WtD+CbInbosDmUwVpcKlTTo6sVllSWMYXiX/HHXWeMxbcUNIkilsj5EDlbKiw7nHQtHYHYQLQMsVdQ== dependencies: - "@floating-ui/react-dom" "^1.3.0" + "@floating-ui/react-dom" "^2.0.0" aria-hidden "^1.1.3" tabbable "^6.0.1"