Merge branch 'translations' into 'develop'
Support translation feature See merge request soapbox-pub/soapbox!1852
This commit is contained in:
commit
639ba856ab
12 changed files with 131 additions and 11 deletions
|
@ -66,7 +66,5 @@ export const loadInstance = createAsyncThunk<void, void, { state: RootState }>(
|
|||
|
||||
export const fetchNodeinfo = createAsyncThunk<void, void, { state: RootState }>(
|
||||
'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'),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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<IStatus> = (props) => {
|
|||
status={actualStatus}
|
||||
onClick={handleClick}
|
||||
collapsable
|
||||
translatable
|
||||
/>
|
||||
|
||||
<TranslateButton status={actualStatus} />
|
||||
|
||||
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
|
||||
<Stack space={4}>
|
||||
<StatusMedia
|
||||
|
|
|
@ -39,10 +39,11 @@ interface IStatusContent {
|
|||
status: Status,
|
||||
onClick?: () => void,
|
||||
collapsable?: boolean,
|
||||
translatable?: boolean,
|
||||
}
|
||||
|
||||
/** 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 [collapsed, setCollapsed] = useState(false);
|
||||
|
@ -154,14 +155,14 @@ const StatusContent: React.FC<IStatusContent> = ({ 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;
|
||||
|
|
59
app/soapbox/components/translate-button.tsx
Normal file
59
app/soapbox/components/translate-button.tsx
Normal 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;
|
|
@ -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<HTMLDivElement> {
|
||||
/** Horizontal alignment of children. */
|
||||
alignItems?: 'center'
|
||||
alignItems?: keyof typeof alignItemsOptions
|
||||
/** Extra class names on the element. */
|
||||
className?: string
|
||||
/** Vertical alignment of children. */
|
||||
|
|
|
@ -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<IDetailedStatus> = ({
|
|||
)}
|
||||
|
||||
<Stack space={4}>
|
||||
<StatusContent status={actualStatus} />
|
||||
<StatusContent status={actualStatus} translatable />
|
||||
|
||||
<TranslateButton status={actualStatus} />
|
||||
|
||||
{(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && (
|
||||
<Stack space={4}>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -63,6 +63,7 @@ export const StatusRecord = ImmutableRecord({
|
|||
hidden: false,
|
||||
search_index: '',
|
||||
spoilerHtml: '',
|
||||
translation: null as ImmutableMap<string, string> | null,
|
||||
});
|
||||
|
||||
const normalizeAttachments = (status: ImmutableMap<string, any>) => {
|
||||
|
|
|
@ -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). */
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue