Merge branch 'translations' into 'develop'
Support translation feature See merge request soapbox-pub/soapbox!1852
This commit is contained in:
commit
639ba856ab
|
@ -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');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
@ -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. */
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>) => {
|
||||||
|
|
|
@ -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). */
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue