diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index 60a6b2e89..151ad3672 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -66,7 +66,5 @@ export const loadInstance = createAsyncThunk( export const fetchNodeinfo = createAsyncThunk( 'nodeinfo/fetch', - async(_arg, { getState }) => { - return await api(getState).get('/nodeinfo/2.1.json'); - }, + async(_arg, { getState }) => await api(getState).get('/nodeinfo/2.1.json'), ); diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index b1bced1f0..f9bfca3b0 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -43,6 +43,11 @@ const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; const STATUS_REVEAL = 'STATUS_REVEAL'; const STATUS_HIDE = 'STATUS_HIDE'; +const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST'; +const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS'; +const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL'; +const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO'; + const statusExists = (getState: () => RootState, statusId: string) => { return (getState().statuses.get(statusId) || null) !== null; }; @@ -305,6 +310,31 @@ const toggleStatusHidden = (status: Status) => { } }; +const translateStatus = (id: string, targetLanguage?: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_TRANSLATE_REQUEST, id }); + + api(getState).post(`/api/v1/statuses/${id}/translate`, { + target_language: targetLanguage, + }).then(response => { + dispatch({ + type: STATUS_TRANSLATE_SUCCESS, + id, + translation: response.data, + }); + }).catch(error => { + dispatch({ + type: STATUS_TRANSLATE_FAIL, + id, + error, + }); + }); +}; + +const undoStatusTranslation = (id: string) => ({ + type: STATUS_TRANSLATE_UNDO, + id, +}); + export { STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, @@ -329,6 +359,10 @@ export { STATUS_UNMUTE_FAIL, STATUS_REVEAL, STATUS_HIDE, + STATUS_TRANSLATE_REQUEST, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_FAIL, + STATUS_TRANSLATE_UNDO, createStatus, editStatus, fetchStatus, @@ -345,4 +379,6 @@ export { hideStatus, revealStatus, toggleStatusHidden, + translateStatus, + undoStatusTranslation, }; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 00ae397c5..961ca5fbe 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -9,6 +9,7 @@ import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { toggleStatusHidden } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; +import TranslateButton from 'soapbox/components/translate-button'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; @@ -370,8 +371,11 @@ const Status: React.FC = (props) => { status={actualStatus} onClick={handleClick} collapsable + translatable /> + + {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( void, collapsable?: boolean, + translatable?: boolean, } /** Renders the text content of a status */ -const StatusContent: React.FC = ({ status, onClick, collapsable = false }) => { +const StatusContent: React.FC = ({ status, onClick, collapsable = false, translatable }) => { const history = useHistory(); const [collapsed, setCollapsed] = useState(false); @@ -154,14 +155,14 @@ const StatusContent: React.FC = ({ status, onClick, collapsable }; const parsedHtml = useMemo((): string => { - const { contentHtml: html } = status; + const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml; if (greentext) { return addGreentext(html); } else { return html; } - }, [status.contentHtml]); + }, [status.contentHtml, status.translation]); if (status.content.length === 0) { return null; diff --git a/app/soapbox/components/translate-button.tsx b/app/soapbox/components/translate-button.tsx new file mode 100644 index 000000000..d39cba1a6 --- /dev/null +++ b/app/soapbox/components/translate-button.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; + +import { Stack } from './ui'; + +import type { Status } from 'soapbox/types/entities'; + +interface ITranslateButton { + status: Status, +} + +const TranslateButton: React.FC = ({ status }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const features = useFeatures(); + + const me = useAppSelector((state) => state.me); + + const renderTranslate = /* translationEnabled && */ me && ['public', 'unlisted'].includes(status.visibility) && status.contentHtml.length > 0 && status.language !== null && intl.locale !== status.language; + + const handleTranslate: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (status.translation) { + dispatch(undoStatusTranslation(status.id)); + } else { + dispatch(translateStatus(status.id, intl.locale)); + } + }; + + if (!features.translations || !renderTranslate) return null; + + if (status.translation) { + const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' }); + const languageName = languageNames.of(status.language!); + const provider = status.translation.get('provider'); + + return ( + + + + + + ); + } + + return ( + + ); +}; + +export default TranslateButton; diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 08d412d80..874629077 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -19,12 +19,15 @@ const justifyContentOptions = { }; const alignItemsOptions = { + top: 'items-start', + bottom: 'items-end', center: 'items-center', + start: 'items-start', }; interface IStack extends React.HTMLAttributes { /** Horizontal alignment of children. */ - alignItems?: 'center' + alignItems?: keyof typeof alignItemsOptions /** Extra class names on the element. */ className?: string /** Vertical alignment of children. */ diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index bb9bfa2f5..eb6fb240f 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -7,6 +7,7 @@ import StatusMedia from 'soapbox/components/status-media'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusContent from 'soapbox/components/status_content'; import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; +import TranslateButton from 'soapbox/components/translate-button'; import { HStack, Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; @@ -101,7 +102,9 @@ const DetailedStatus: React.FC = ({ )} - + + + {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 4385b4446..23a13c2ba 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -1211,8 +1211,11 @@ "status.show_less_all": "Zwiń wszystkie", "status.show_more": "Rozwiń", "status.show_more_all": "Rozwiń wszystkie", + "status.show_original": "Pokaż oryginalny wpis", "status.title": "Wpis", "status.title_direct": "Wiadomość bezpośrednia", + "status.translated_from_with": "Przetłumaczono z {lang} z użyciem {provider}", + "status.translate": "Przetłumacz wpis", "status.unbookmark": "Usuń z zakładek", "status.unbookmarked": "Usunięto z zakładek.", "status.unmute_conversation": "Cofnij wyciszenie konwersacji", diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index ea971c52f..41ebacfc7 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -63,6 +63,7 @@ export const StatusRecord = ImmutableRecord({ hidden: false, search_index: '', spoilerHtml: '', + translation: null as ImmutableMap | null, }); const normalizeAttachments = (status: ImmutableMap) => { diff --git a/app/soapbox/precheck.ts b/app/soapbox/precheck.ts index 79d523d87..03fea80a1 100644 --- a/app/soapbox/precheck.ts +++ b/app/soapbox/precheck.ts @@ -3,10 +3,10 @@ * @module soapbox/precheck */ -/** Whether pre-rendered data exists in Mastodon's format. */ +/** Whether pre-rendered data exists in Pleroma's format. */ const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); -/** Whether pre-rendered data exists in Pleroma's format. */ +/** Whether pre-rendered data exists in Mastodon's format. */ const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); /** Whether initial data was loaded into the page by server-side-rendering (SSR). */ diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 49c946aa0..088191edd 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -1,5 +1,5 @@ import escapeTextContentForBrowser from 'escape-html'; -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import emojify from 'soapbox/features/emoji/emoji'; import { normalizeStatus } from 'soapbox/normalizers'; @@ -30,6 +30,8 @@ import { STATUS_HIDE, STATUS_DELETE_REQUEST, STATUS_DELETE_FAIL, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_UNDO, } from '../actions/statuses'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -255,6 +257,10 @@ export default function statuses(state = initialState, action: AnyAction): State return decrementReplyCount(state, action.params); case STATUS_DELETE_FAIL: return incrementReplyCount(state, action.params); + case STATUS_TRANSLATE_SUCCESS: + return state.setIn([action.id, 'translation'], fromJS(action.translation)); + case STATUS_TRANSLATE_UNDO: + return state.deleteIn([action.id, 'translation']); case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); default: diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 9d2405143..823f8df1a 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -617,6 +617,12 @@ const getInstanceFeatures = (instance: Instance) => { features.includes('v2_suggestions'), ]), + /** + * Can translate statuses. + * @see POST /api/v1/statuses/:id/translate + */ + translations: features.includes('translation'), + /** * Trending statuses. * @see GET /api/v1/trends/statuses