From 3448022965bea8fae6d3b8b88c9fc47bd62b52fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 27 Oct 2022 19:46:03 +0200 Subject: [PATCH 1/2] Support translation feature on Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/instance.ts | 20 ++++++- app/soapbox/actions/statuses.ts | 34 +++++++++++ app/soapbox/components/status.tsx | 4 ++ app/soapbox/components/status_content.tsx | 7 ++- app/soapbox/components/translate-button.tsx | 59 +++++++++++++++++++ app/soapbox/components/ui/stack/stack.tsx | 5 +- .../status/components/detailed-status.tsx | 4 ++ app/soapbox/locales/pl.json | 3 + app/soapbox/normalizers/status.ts | 1 + app/soapbox/precheck.ts | 4 +- app/soapbox/reducers/instance.ts | 13 ++++ app/soapbox/reducers/statuses.ts | 8 ++- app/soapbox/utils/features.ts | 12 ++++ 13 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 app/soapbox/components/translate-button.tsx diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index 60a6b2e89..3a45abf49 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -1,5 +1,6 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import get from 'lodash/get'; +import { gte } from 'semver'; import KVStore from 'soapbox/storage/kv_store'; import { RootState } from 'soapbox/store'; @@ -37,6 +38,12 @@ const needsNodeinfo = (instance: Record): boolean => { return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); }; +/** Mastodon exposes features availabiliy under /api/v2/instance since 4.0.0 */ +const supportsInstanceV2 = (instance: Record): boolean => { + const v = parseVersion(get(instance, 'version')); + return v.software === 'Mastodon' && gte(v.compatVersion, '4.0.0'); +}; + export const fetchInstance = createAsyncThunk( 'instance/fetch', async(_arg, { dispatch, getState, rejectWithValue }) => { @@ -45,6 +52,9 @@ export const fetchInstance = createAsyncThunk( if (needsNodeinfo(instance)) { dispatch(fetchNodeinfo()); } + if (supportsInstanceV2(instance)) { + dispatch(fetchInstanceV2()); + } return instance; } catch (e) { return rejectWithValue(e); @@ -64,9 +74,15 @@ export const loadInstance = createAsyncThunk( }, ); -export const fetchNodeinfo = createAsyncThunk( +export const fetchInstanceV2 = createAsyncThunk( 'nodeinfo/fetch', async(_arg, { getState }) => { - return await api(getState).get('/nodeinfo/2.1.json'); + const { data: instance } = await api(getState).get('/api/v2/instance'); + return instance; }, ); + +export const fetchNodeinfo = createAsyncThunk( + 'nodeinfo/fetch', + 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..5ebf1c95c 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,29 @@ const toggleStatusHidden = (status: Status) => { } }; +const translateStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: STATUS_TRANSLATE_REQUEST, id }); + + api(getState).post(`/api/v1/statuses/${id}/translate`).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 +357,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 +377,6 @@ export { hideStatus, revealStatus, toggleStatusHidden, + translateStatus, + undoStatusTranslation, }; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 35827020f..12d74e6f9 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'; @@ -385,8 +386,11 @@ const Status: React.FC = (props) => { expanded={!status.hidden} onExpandedToggle={handleExpandedToggle} collapsable + translatable /> + + void, onClick?: () => void, collapsable?: boolean, + translatable?: boolean, } /** Renders the text content of a status */ -const StatusContent: React.FC = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false }) => { +const StatusContent: React.FC = ({ status, expanded = false, onExpandedToggle, onClick, collapsable = false, translatable }) => { const history = useHistory(); const [hidden, setHidden] = useState(true); @@ -199,14 +200,14 @@ const StatusContent: React.FC = ({ status, expanded = false, onE }; 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..5c6334fd3 --- /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)); + } + }; + + 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 660f46e36..bc7728af5 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -19,14 +19,17 @@ const justifyContentOptions = { }; const alignItemsOptions = { + top: 'items-start', + bottom: 'items-end', center: 'items-center', + start: 'items-start', }; interface IStack extends React.HTMLAttributes { /** Size of the gap between elements. */ space?: keyof typeof spaces /** Horizontal alignment of children. */ - alignItems?: 'center' + alignItems?: keyof typeof alignItemsOptions /** Vertical alignment of children. */ justifyContent?: keyof typeof justifyContentOptions /** Extra class names on the
element. */ diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 52bc578af..cb44518da 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'; @@ -109,8 +110,11 @@ const DetailedStatus: React.FC = ({ status={actualStatus} expanded={!actualStatus.hidden} onExpandedToggle={handleExpandedToggle} + translatable /> + + | 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/instance.ts b/app/soapbox/reducers/instance.ts index 4c1456dc4..ad6b5ae2f 100644 --- a/app/soapbox/reducers/instance.ts +++ b/app/soapbox/reducers/instance.ts @@ -10,6 +10,7 @@ import { rememberInstance, fetchInstance, fetchNodeinfo, + fetchInstanceV2, } from '../actions/instance'; import type { AnyAction } from 'redux'; @@ -32,10 +33,20 @@ const nodeinfoToInstance = (nodeinfo: ImmutableMap) => { })); }; +const instanceV2ToInstance = (instanceV2: ImmutableMap) => + normalizeInstance(ImmutableMap({ + configuration: instanceV2.get('configuration'), + })); + const importInstance = (_state: typeof initialState, instance: ImmutableMap) => { return normalizeInstance(instance); }; +const importInstanceV2 = (state: typeof initialState, instanceV2: ImmutableMap) => { + console.log(instanceV2.toJS()); + return state.mergeDeep(instanceV2ToInstance(instanceV2)); +}; + const importNodeinfo = (state: typeof initialState, nodeinfo: ImmutableMap) => { return nodeinfoToInstance(nodeinfo).mergeDeep(state); }; @@ -120,6 +131,8 @@ export default function instance(state = initialState, action: AnyAction) { case fetchInstance.fulfilled.type: persistInstance(action.payload); return importInstance(state, ImmutableMap(fromJS(action.payload))); + case fetchInstanceV2.fulfilled.type: + return importInstanceV2(state, ImmutableMap(fromJS(action.payload))); case fetchInstance.rejected.type: return handleInstanceFetchFail(state, action.error); case fetchNodeinfo.fulfilled.type: 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 f220f5e67..716809612 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -353,6 +353,12 @@ const getInstanceFeatures = (instance: Instance) => { */ importData: v.software === PLEROMA && gte(v.version, '2.2.0'), + /** + * Supports V2 instance endpoint. + * @see GET /api/v2/instance + */ + instanceV2: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + /** * Can create, view, and manage lists. * @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} @@ -608,6 +614,12 @@ const getInstanceFeatures = (instance: Instance) => { features.includes('v2_suggestions'), ]), + /** + * Can translate statuses. + * @see POST /api/v1/statuses/:id/translate + */ + translations: v.software === MASTODON && instance.configuration.getIn(['translation', 'enabled'], false), + /** * Trending statuses. * @see GET /api/v1/trends/statuses From 1ea4ae3a573f03ab301b37bb37ca8d15cbdaba2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 4 Nov 2022 22:36:39 +0100 Subject: [PATCH 2/2] Only support Pleroma for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/instance.ts | 18 ------------------ app/soapbox/actions/statuses.ts | 6 ++++-- app/soapbox/components/translate-button.tsx | 2 +- app/soapbox/reducers/instance.ts | 13 ------------- app/soapbox/utils/features.ts | 8 +------- 5 files changed, 6 insertions(+), 41 deletions(-) diff --git a/app/soapbox/actions/instance.ts b/app/soapbox/actions/instance.ts index 3a45abf49..151ad3672 100644 --- a/app/soapbox/actions/instance.ts +++ b/app/soapbox/actions/instance.ts @@ -1,6 +1,5 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import get from 'lodash/get'; -import { gte } from 'semver'; import KVStore from 'soapbox/storage/kv_store'; import { RootState } from 'soapbox/store'; @@ -38,12 +37,6 @@ const needsNodeinfo = (instance: Record): boolean => { return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); }; -/** Mastodon exposes features availabiliy under /api/v2/instance since 4.0.0 */ -const supportsInstanceV2 = (instance: Record): boolean => { - const v = parseVersion(get(instance, 'version')); - return v.software === 'Mastodon' && gte(v.compatVersion, '4.0.0'); -}; - export const fetchInstance = createAsyncThunk( 'instance/fetch', async(_arg, { dispatch, getState, rejectWithValue }) => { @@ -52,9 +45,6 @@ export const fetchInstance = createAsyncThunk( if (needsNodeinfo(instance)) { dispatch(fetchNodeinfo()); } - if (supportsInstanceV2(instance)) { - dispatch(fetchInstanceV2()); - } return instance; } catch (e) { return rejectWithValue(e); @@ -74,14 +64,6 @@ export const loadInstance = createAsyncThunk( }, ); -export const fetchInstanceV2 = createAsyncThunk( - 'nodeinfo/fetch', - async(_arg, { getState }) => { - const { data: instance } = await api(getState).get('/api/v2/instance'); - return instance; - }, -); - export const fetchNodeinfo = createAsyncThunk( 'nodeinfo/fetch', 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 5ebf1c95c..f9bfca3b0 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -310,10 +310,12 @@ const toggleStatusHidden = (status: Status) => { } }; -const translateStatus = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { +const translateStatus = (id: string, targetLanguage?: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: STATUS_TRANSLATE_REQUEST, id }); - api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => { + api(getState).post(`/api/v1/statuses/${id}/translate`, { + target_language: targetLanguage, + }).then(response => { dispatch({ type: STATUS_TRANSLATE_SUCCESS, id, diff --git a/app/soapbox/components/translate-button.tsx b/app/soapbox/components/translate-button.tsx index 5c6334fd3..d39cba1a6 100644 --- a/app/soapbox/components/translate-button.tsx +++ b/app/soapbox/components/translate-button.tsx @@ -27,7 +27,7 @@ const TranslateButton: React.FC = ({ status }) => { if (status.translation) { dispatch(undoStatusTranslation(status.id)); } else { - dispatch(translateStatus(status.id)); + dispatch(translateStatus(status.id, intl.locale)); } }; diff --git a/app/soapbox/reducers/instance.ts b/app/soapbox/reducers/instance.ts index ad6b5ae2f..4c1456dc4 100644 --- a/app/soapbox/reducers/instance.ts +++ b/app/soapbox/reducers/instance.ts @@ -10,7 +10,6 @@ import { rememberInstance, fetchInstance, fetchNodeinfo, - fetchInstanceV2, } from '../actions/instance'; import type { AnyAction } from 'redux'; @@ -33,20 +32,10 @@ const nodeinfoToInstance = (nodeinfo: ImmutableMap) => { })); }; -const instanceV2ToInstance = (instanceV2: ImmutableMap) => - normalizeInstance(ImmutableMap({ - configuration: instanceV2.get('configuration'), - })); - const importInstance = (_state: typeof initialState, instance: ImmutableMap) => { return normalizeInstance(instance); }; -const importInstanceV2 = (state: typeof initialState, instanceV2: ImmutableMap) => { - console.log(instanceV2.toJS()); - return state.mergeDeep(instanceV2ToInstance(instanceV2)); -}; - const importNodeinfo = (state: typeof initialState, nodeinfo: ImmutableMap) => { return nodeinfoToInstance(nodeinfo).mergeDeep(state); }; @@ -131,8 +120,6 @@ export default function instance(state = initialState, action: AnyAction) { case fetchInstance.fulfilled.type: persistInstance(action.payload); return importInstance(state, ImmutableMap(fromJS(action.payload))); - case fetchInstanceV2.fulfilled.type: - return importInstanceV2(state, ImmutableMap(fromJS(action.payload))); case fetchInstance.rejected.type: return handleInstanceFetchFail(state, action.error); case fetchNodeinfo.fulfilled.type: diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 716809612..f70402873 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -353,12 +353,6 @@ const getInstanceFeatures = (instance: Instance) => { */ importData: v.software === PLEROMA && gte(v.version, '2.2.0'), - /** - * Supports V2 instance endpoint. - * @see GET /api/v2/instance - */ - instanceV2: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), - /** * Can create, view, and manage lists. * @see {@link https://docs.joinmastodon.org/methods/timelines/lists/} @@ -618,7 +612,7 @@ const getInstanceFeatures = (instance: Instance) => { * Can translate statuses. * @see POST /api/v1/statuses/:id/translate */ - translations: v.software === MASTODON && instance.configuration.getIn(['translation', 'enabled'], false), + translations: features.includes('translation'), /** * Trending statuses.