diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 5930b7a160..a3e2a24f28 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -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, @@ -310,4 +315,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, +}); diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 800832dc8e..4041b48194 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -83,6 +83,7 @@ class Status extends ImmutablePureComponent { onEmbed: PropTypes.func, onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, + onTranslate: PropTypes.func, onInteractionModal: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, @@ -472,6 +473,10 @@ class Status extends ImmutablePureComponent { this.node = c; } + handleTranslate = () => { + this.props.onTranslate(this.props.status); + } + renderLoadingMediaGallery () { return
; } @@ -788,6 +793,7 @@ class Status extends ImmutablePureComponent { mediaIcons={contentMediaIcons} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} + onTranslate={this.handleTranslate} parseClick={parseClick} disabled={!router} tagLinks={settings.get('tag_misleading_links')} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index c618cedcab..c59f42220c 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -1,11 +1,11 @@ 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 Icon from 'flavours/glitch/components/icon'; -import { autoPlayGif } from 'flavours/glitch/initial_state'; +import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'flavours/glitch/initial_state'; import { decode as decodeIDNA } from 'flavours/glitch/utils/idna'; const textMatchesTarget = (text, origin, host) => { @@ -62,13 +62,56 @@ const isLinkMisleading = (link) => { return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); }; -export default class StatusContent extends React.PureComponent { +class TranslateButton extends React.PureComponent { + + static propTypes = { + translation: ImmutablePropTypes.map, + onClick: PropTypes.func, + }; + + render () { + const { translation, onClick } = this.props; + + if (translation) { + const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language')); + const languageName = language ? language[2] : translation.get('detected_source_language'); + const provider = translation.get('provider'); + + return ( +
+
+ +
+ + +
+ ); + } + + return ( + + ); + } + +} + +export default @injectIntl +class StatusContent extends React.PureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; static propTypes = { status: ImmutablePropTypes.map.isRequired, expanded: PropTypes.bool, collapsed: PropTypes.bool, onExpandedToggle: PropTypes.func, + onTranslate: PropTypes.func, media: PropTypes.node, extraMedia: PropTypes.node, mediaIcons: PropTypes.arrayOf(PropTypes.string), @@ -77,6 +120,7 @@ export default class StatusContent extends React.PureComponent { onUpdate: PropTypes.func, tagLinks: PropTypes.bool, rewriteMentions: PropTypes.string, + intl: PropTypes.object, }; static defaultProps = { @@ -249,6 +293,10 @@ export default class StatusContent extends React.PureComponent { } } + handleTranslate = () => { + this.props.onTranslate(); + } + setContentsRef = (c) => { this.contentsNode = c; } @@ -263,18 +311,24 @@ export default class StatusContent extends React.PureComponent { disabled, tagLinks, rewriteMentions, + intl, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; + const renderTranslate = translationEnabled && this.context.identity.signedIn && this.props.onTranslate && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && status.get('language') !== null && intl.locale !== status.get('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': parseClick && !disabled, 'status__content--with-spoiler': status.get('spoiler_text').length > 0, }); + const translateButton = renderTranslate && ( + + ); + if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -350,11 +404,11 @@ export default class StatusContent extends React.PureComponent { onMouseLeave={this.handleMouseLeave} lang={lang} /> + {!hidden && translateButton} {media}
{extraMedia} - ); } else if (parseClick) { @@ -375,6 +429,7 @@ export default class StatusContent extends React.PureComponent { onMouseLeave={this.handleMouseLeave} lang={lang} /> + {translateButton} {media} {extraMedia} @@ -395,6 +450,7 @@ export default class StatusContent extends React.PureComponent { onMouseLeave={this.handleMouseLeave} lang={lang} /> + {translateButton} {media} {extraMedia} diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index c12b2e6143..947573fc7d 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -23,7 +23,9 @@ import { deleteStatus, hideStatus, revealStatus, - editStatus + editStatus, + translateStatus, + undoStatusTranslation, } from 'flavours/glitch/actions/statuses'; import { initAddFilter, @@ -187,6 +189,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)); }, diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 46770930f5..7d2c2aace3 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -34,6 +34,7 @@ class DetailedStatus extends ImmutablePureComponent { onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, onToggleHidden: PropTypes.func, + onTranslate: PropTypes.func.isRequired, expanded: PropTypes.bool, measureHeight: PropTypes.bool, onHeightChange: PropTypes.func, @@ -112,6 +113,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 { expanded, onToggleHidden, settings, usingPiP, intl } = this.props; @@ -305,6 +311,7 @@ class DetailedStatus extends ImmutablePureComponent { expanded={expanded} collapsed={false} onExpandedToggle={onToggleHidden} + onTranslate={this.handleTranslate} parseClick={this.parseClick} onUpdate={this.handleChildUpdate} tagLinks={settings.get('tag_misleading_links')} diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index aaa9c7928f..e190652b06 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -33,7 +33,9 @@ import { deleteStatus, editStatus, hideStatus, - revealStatus + revealStatus, + translateStatus, + undoStatusTranslation, } from 'flavours/glitch/actions/statuses'; import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; @@ -437,6 +439,16 @@ class Status extends ImmutablePureComponent { this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded }); } + 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'); @@ -666,6 +678,7 @@ class Status extends ImmutablePureComponent { onOpenMedia={this.handleOpenMedia} expanded={isExpanded} onToggleHidden={this.handleToggleHidden} + onTranslate={this.handleTranslate} domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index 5be177cedf..bbf25c8a85 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -79,6 +79,7 @@ * @property {boolean} use_blurhash * @property {boolean=} use_pending_items * @property {string} version + * @property {boolean} translation_enabled * @property {object} local_settings */ @@ -137,6 +138,7 @@ export const unfollowModal = getMeta('unfollow_modal'); export const useBlurhash = getMeta('use_blurhash'); export const usePendingItems = getMeta('use_pending_items'); export const version = getMeta('version'); +export const translationEnabled = getMeta('translation_enabled'); export const languages = initialState?.languages; // Glitch-soc-specific settings diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index b47155c5f6..f0c4c804b7 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -13,6 +13,8 @@ import { STATUS_REVEAL, STATUS_HIDE, STATUS_COLLAPSE, + STATUS_TRANSLATE_SUCCESS, + STATUS_TRANSLATE_UNDO, STATUS_FETCH_REQUEST, STATUS_FETCH_FAIL, } from 'flavours/glitch/actions/statuses'; @@ -85,6 +87,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; } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 80b0598a50..84aca2ebc0 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -15,7 +15,7 @@ display: block; font-size: 15px; line-height: 20px; - color: $ui-highlight-color; + color: $highlight-text-color; border: 0; background: transparent; padding: 0; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 054110e410..7cc6f5386b 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -206,15 +206,13 @@ } } -.status__content__edited-label { - display: block; - cursor: default; +.translate-button { + margin-top: 16px; font-size: 15px; line-height: 20px; - padding: 0; - padding-top: 8px; + display: flex; + justify-content: space-between; color: $dark-text-color; - font-weight: 500; } .status__content__spoiler-link { diff --git a/app/javascript/flavours/glitch/styles/statuses.scss b/app/javascript/flavours/glitch/styles/statuses.scss index 947a5d3aed..88fa3ffa07 100644 --- a/app/javascript/flavours/glitch/styles/statuses.scss +++ b/app/javascript/flavours/glitch/styles/statuses.scss @@ -268,7 +268,7 @@ a.button.logo-button { border: 0; background: transparent; padding: 0; - padding-top: 8px; + padding-top: 16px; text-decoration: none; &:hover,