Merge branch 'translations' into 'develop'

Support translation feature

See merge request soapbox-pub/soapbox!1852
This commit is contained in:
marcin mikołajczak 2022-11-05 22:08:59 +00:00
commit 639ba856ab
12 changed files with 131 additions and 11 deletions

View File

@ -66,7 +66,5 @@ export const loadInstance = createAsyncThunk<void, void, { state: RootState }>(
export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>( export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
'nodeinfo/fetch', 'nodeinfo/fetch',
async(_arg, { getState }) => { async(_arg, { getState }) => await api(getState).get('/nodeinfo/2.1.json'),
return await api(getState).get('/nodeinfo/2.1.json');
},
); );

View File

@ -43,6 +43,11 @@ const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
const STATUS_REVEAL = 'STATUS_REVEAL'; const STATUS_REVEAL = 'STATUS_REVEAL';
const STATUS_HIDE = 'STATUS_HIDE'; 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) => { const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null; 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 { export {
STATUS_CREATE_REQUEST, STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS, STATUS_CREATE_SUCCESS,
@ -329,6 +359,10 @@ export {
STATUS_UNMUTE_FAIL, STATUS_UNMUTE_FAIL,
STATUS_REVEAL, STATUS_REVEAL,
STATUS_HIDE, STATUS_HIDE,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO,
createStatus, createStatus,
editStatus, editStatus,
fetchStatus, fetchStatus,
@ -345,4 +379,6 @@ export {
hideStatus, hideStatus,
revealStatus, revealStatus,
toggleStatusHidden, toggleStatusHidden,
translateStatus,
undoStatusTranslation,
}; };

View File

@ -9,6 +9,7 @@ import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import TranslateButton from 'soapbox/components/translate-button';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { useAppDispatch, useSettings } from 'soapbox/hooks';
@ -370,8 +371,11 @@ const Status: React.FC<IStatus> = (props) => {
status={actualStatus} status={actualStatus}
onClick={handleClick} onClick={handleClick}
collapsable collapsable
translatable
/> />
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}> <Stack space={4}>
<StatusMedia <StatusMedia

View File

@ -39,10 +39,11 @@ interface IStatusContent {
status: Status, status: Status,
onClick?: () => void, onClick?: () => void,
collapsable?: boolean, collapsable?: boolean,
translatable?: boolean,
} }
/** Renders the text content of a status */ /** Renders the text content of a status */
const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false }) => { const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable = false, translatable }) => {
const history = useHistory(); const history = useHistory();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@ -154,14 +155,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
}; };
const parsedHtml = useMemo((): string => { const parsedHtml = useMemo((): string => {
const { contentHtml: html } = status; const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
if (greentext) { if (greentext) {
return addGreentext(html); return addGreentext(html);
} else { } else {
return html; return html;
} }
}, [status.contentHtml]); }, [status.contentHtml, status.translation]);
if (status.content.length === 0) { if (status.content.length === 0) {
return null; return null;

View File

@ -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<ITranslateButton> = ({ 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<HTMLButtonElement> = (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 (
<Stack className='text-gray-700 dark:text-gray-600 text-sm' alignItems='start'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue hover:underline' onClick={handleTranslate}>
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
</button>
</Stack>
);
}
return (
<button className='text-primary-600 dark:text-accent-blue hover:text-primary-700 dark:hover:text-accent-blue text-left text-sm hover:underline' onClick={handleTranslate}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);
};
export default TranslateButton;

View File

@ -19,12 +19,15 @@ const justifyContentOptions = {
}; };
const alignItemsOptions = { const alignItemsOptions = {
top: 'items-start',
bottom: 'items-end',
center: 'items-center', center: 'items-center',
start: 'items-start',
}; };
interface IStack extends React.HTMLAttributes<HTMLDivElement> { interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Horizontal alignment of children. */ /** Horizontal alignment of children. */
alignItems?: 'center' alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the element. */ /** Extra class names on the element. */
className?: string className?: string
/** Vertical alignment of children. */ /** Vertical alignment of children. */

View File

@ -7,6 +7,7 @@ import StatusMedia from 'soapbox/components/status-media';
import StatusReplyMentions from 'soapbox/components/status-reply-mentions'; import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
import StatusContent from 'soapbox/components/status_content'; import StatusContent from 'soapbox/components/status_content';
import SensitiveContentOverlay from 'soapbox/components/statuses/sensitive-content-overlay'; 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 { HStack, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container'; import AccountContainer from 'soapbox/containers/account_container';
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
@ -101,7 +102,9 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
)} )}
<Stack space={4}> <Stack space={4}>
<StatusContent status={actualStatus} /> <StatusContent status={actualStatus} translatable />
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
<Stack space={4}> <Stack space={4}>

View File

@ -1211,8 +1211,11 @@
"status.show_less_all": "Zwiń wszystkie", "status.show_less_all": "Zwiń wszystkie",
"status.show_more": "Rozwiń", "status.show_more": "Rozwiń",
"status.show_more_all": "Rozwiń wszystkie", "status.show_more_all": "Rozwiń wszystkie",
"status.show_original": "Pokaż oryginalny wpis",
"status.title": "Wpis", "status.title": "Wpis",
"status.title_direct": "Wiadomość bezpośrednia", "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.unbookmark": "Usuń z zakładek",
"status.unbookmarked": "Usunięto z zakładek.", "status.unbookmarked": "Usunięto z zakładek.",
"status.unmute_conversation": "Cofnij wyciszenie konwersacji", "status.unmute_conversation": "Cofnij wyciszenie konwersacji",

View File

@ -63,6 +63,7 @@ export const StatusRecord = ImmutableRecord({
hidden: false, hidden: false,
search_index: '', search_index: '',
spoilerHtml: '', spoilerHtml: '',
translation: null as ImmutableMap<string, string> | null,
}); });
const normalizeAttachments = (status: ImmutableMap<string, any>) => { const normalizeAttachments = (status: ImmutableMap<string, any>) => {

View File

@ -3,10 +3,10 @@
* @module soapbox/precheck * @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')); 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')); const hasPrerenderMastodon = Boolean(document.getElementById('initial-state'));
/** Whether initial data was loaded into the page by server-side-rendering (SSR). */ /** Whether initial data was loaded into the page by server-side-rendering (SSR). */

View File

@ -1,5 +1,5 @@
import escapeTextContentForBrowser from 'escape-html'; 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 emojify from 'soapbox/features/emoji/emoji';
import { normalizeStatus } from 'soapbox/normalizers'; import { normalizeStatus } from 'soapbox/normalizers';
@ -30,6 +30,8 @@ import {
STATUS_HIDE, STATUS_HIDE,
STATUS_DELETE_REQUEST, STATUS_DELETE_REQUEST,
STATUS_DELETE_FAIL, STATUS_DELETE_FAIL,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
} from '../actions/statuses'; } from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
@ -255,6 +257,10 @@ export default function statuses(state = initialState, action: AnyAction): State
return decrementReplyCount(state, action.params); return decrementReplyCount(state, action.params);
case STATUS_DELETE_FAIL: case STATUS_DELETE_FAIL:
return incrementReplyCount(state, action.params); 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: case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references); return deleteStatus(state, action.id, action.references);
default: default:

View File

@ -617,6 +617,12 @@ const getInstanceFeatures = (instance: Instance) => {
features.includes('v2_suggestions'), features.includes('v2_suggestions'),
]), ]),
/**
* Can translate statuses.
* @see POST /api/v1/statuses/:id/translate
*/
translations: features.includes('translation'),
/** /**
* Trending statuses. * Trending statuses.
* @see GET /api/v1/trends/statuses * @see GET /api/v1/trends/statuses