Skip to content

Commit

Permalink
Add user content translations with configurable backends (mastodon#19218
Browse files Browse the repository at this point in the history
)
  • Loading branch information
Gargron authored and Nonexistent committed Sep 26, 2022
1 parent f1c9ae8 commit 8232c75
Show file tree
Hide file tree
Showing 16 changed files with 306 additions and 11 deletions.
29 changes: 29 additions & 0 deletions app/controllers/api/v1/statuses/translations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

class Api::V1::Statuses::TranslationsController < Api::BaseController
include Authorization

before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :set_status
before_action :set_translation

rescue_from TranslationService::NotConfiguredError, with: :not_found
rescue_from TranslationService::UnexpectedResponseError, TranslationService::QuotaExceededError, TranslationService::TooManyRequestsError, with: :service_unavailable

def create
render json: @translation, serializer: REST::TranslationSerializer
end

private

def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end

def set_translation
@translation = TranslateStatusService.new.call(@status, content_locale)
end
end
39 changes: 38 additions & 1 deletion app/javascript/mastodon/actions/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
export const STATUS_FETCH_SOURCE_FAIL = 'STATUS_FETCH_SOURCE_FAIL';

export const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST';
export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';

export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
Expand Down Expand Up @@ -309,4 +314,36 @@ export function toggleStatusCollapse(id, isCollapsed) {
id,
isCollapsed,
};
}
};

export const translateStatus = id => (dispatch, getState) => {
dispatch(translateStatusRequest(id));

api(getState).post(`/api/v1/statuses/${id}/translate`).then(response => {
dispatch(translateStatusSuccess(id, response.data));
}).catch(error => {
dispatch(translateStatusFail(id, error));
});
};

export const translateStatusRequest = id => ({
type: STATUS_TRANSLATE_REQUEST,
id,
});

export const translateStatusSuccess = (id, translation) => ({
type: STATUS_TRANSLATE_SUCCESS,
id,
translation,
});

export const translateStatusFail = (id, error) => ({
type: STATUS_TRANSLATE_FAIL,
id,
error,
});

export const undoStatusTranslation = id => ({
type: STATUS_TRANSLATE_UNDO,
id,
});
16 changes: 15 additions & 1 deletion app/javascript/mastodon/components/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class Status extends ImmutablePureComponent {
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
Expand Down Expand Up @@ -171,6 +172,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}

handleTranslate = () => {
this.props.onTranslate(this._properStatus());
}

renderLoadingMediaGallery () {
return <div className='media-gallery' style={{ height: '110px' }} />;
}
Expand Down Expand Up @@ -512,7 +517,16 @@ class Status extends ImmutablePureComponent {
</a>
</div>

<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
<StatusContent
status={status}
onClick={this.handleClick}
expanded={!status.get('hidden')}
showThread={showThread}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsable
onCollapsedToggle={this.handleCollapsedToggle}
/>

{media}

Expand Down
31 changes: 23 additions & 8 deletions app/javascript/mastodon/components/status_content.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, injectIntl } from 'react-intl';
import Permalink from './permalink';
import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
Expand All @@ -10,7 +10,8 @@ import { autoPlayGif } from 'mastodon/initial_state';

const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)

export default class StatusContent extends React.PureComponent {
export default @injectIntl
class StatusContent extends React.PureComponent {

static contextTypes = {
router: PropTypes.object,
Expand All @@ -21,9 +22,11 @@ export default class StatusContent extends React.PureComponent {
expanded: PropTypes.bool,
showThread: PropTypes.bool,
onExpandedToggle: PropTypes.func,
onTranslate: PropTypes.func,
onClick: PropTypes.func,
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
intl: PropTypes.object,
};

state = {
Expand Down Expand Up @@ -163,20 +166,26 @@ export default class StatusContent extends React.PureComponent {
}
}

handleTranslate = () => {
this.props.onTranslate();
}

setRef = (c) => {
this.node = c;
}

render () {
const { status } = this.props;
const { status, intl } = this.props;

const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const renderTranslate = this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && intl.locale !== status.get('language');
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });

const content = { __html: status.get('contentHtml') };
const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') };
const lang = status.get('language');
const lang = status.get('translation') ? intl.locale : status.get('language');
const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router,
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
Expand All @@ -195,6 +204,12 @@ export default class StatusContent extends React.PureComponent {
</button>
);

const translateButton = (
<button className='status__content__read-more-button' onClick={this.handleTranslate}>
{status.get('translation') ? <span><FormattedMessage id='status.translated_from' defaultMessage='Translated from {lang}' values={{ lang: languageNames.of(status.get('language')) }} /> · <FormattedMessage id='status.show_original' defaultMessage='Show original' /></span> : <FormattedMessage id='status.translate' defaultMessage='Translate' />}
</button>
);

if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';

Expand Down Expand Up @@ -223,7 +238,7 @@ export default class StatusContent extends React.PureComponent {
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} />

{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

{!hidden && renderTranslate && translateButton}
{renderViewThread && showThreadButton}
</div>
);
Expand All @@ -233,7 +248,7 @@ export default class StatusContent extends React.PureComponent {
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} />

{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

{renderTranslate && translateButton}
{renderViewThread && showThreadButton}
</div>,
];
Expand All @@ -249,7 +264,7 @@ export default class StatusContent extends React.PureComponent {
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} />

{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

{renderTranslate && translateButton}
{renderViewThread && showThreadButton}
</div>
);
Expand Down
10 changes: 10 additions & 0 deletions app/javascript/mastodon/containers/status_container.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
revealStatus,
toggleStatusCollapse,
editStatus,
translateStatus,
undoStatusTranslation,
} from '../actions/statuses';
import {
unmuteAccount,
Expand Down Expand Up @@ -150,6 +152,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(editStatus(status.get('id'), history));
},

onTranslate (status) {
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id')));
} else {
dispatch(translateStatus(status.get('id')));
}
},

onDirect (account, router) {
dispatch(directCompose(account, router));
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onTranslate: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
Expand Down Expand Up @@ -103,6 +104,11 @@ class DetailedStatus extends ImmutablePureComponent {
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}

handleTranslate = () => {
const { onTranslate, status } = this.props;
onTranslate(status);
}

render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
Expand Down Expand Up @@ -260,7 +266,12 @@ class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>

<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
<StatusContent
status={status}
expanded={!status.get('hidden')}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
/>

{media}

Expand Down
13 changes: 13 additions & 0 deletions app/javascript/mastodon/features/status/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
editStatus,
hideStatus,
revealStatus,
translateStatus,
undoStatusTranslation,
} from '../../actions/statuses';
import {
unblockAccount,
Expand Down Expand Up @@ -339,6 +341,16 @@ class Status extends ImmutablePureComponent {
}
}

handleTranslate = status => {
const { dispatch } = this.props;

if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id')));
} else {
dispatch(translateStatus(status.get('id')));
}
}

handleBlockClick = (status) => {
const { dispatch } = this.props;
const account = status.get('account');
Expand Down Expand Up @@ -558,6 +570,7 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
Expand Down
6 changes: 6 additions & 0 deletions app/javascript/mastodon/reducers/statuses.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
STATUS_REVEAL,
STATUS_HIDE,
STATUS_COLLAPSE,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
Expand Down Expand Up @@ -77,6 +79,10 @@ export default function statuses(state = initialState, action) {
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case STATUS_TRANSLATE_SUCCESS:
return state.setIn([action.id, 'translation'], fromJS(action.translation));
case STATUS_TRANSLATE_UNDO:
return state.deleteIn([action.id, 'translation']);
default:
return state;
}
Expand Down
23 changes: 23 additions & 0 deletions app/lib/translation_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

class TranslationService
class Error < StandardError; end
class NotConfiguredError < Error; end
class TooManyRequestsError < Error; end
class QuotaExceededError < Error; end
class UnexpectedResponseError < Error; end

def self.configured
if ENV['DEEPL_API_KEY'].present?
TranslationService::DeepL.new(ENV.fetch('DEEPL_PLAN', 'free'), ENV['DEEPL_API_KEY'])
elsif ENV['LIBRE_TRANSLATE_ENDPOINT'].present?
TranslationService::LibreTranslate.new(ENV['LIBRE_TRANSLATE_ENDPOINT'], ENV['LIBRE_TRANSLATE_API_KEY'])
else
raise NotConfiguredError
end
end

def translate(_text, _source_language, _target_language)
raise NotImplementedError
end
end
Loading

0 comments on commit 8232c75

Please sign in to comment.