Translate CW, poll options and media descriptions (#24175)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							44cd88adc4
						
					
				
				
					commit
					69057467cb
				
			| @@ -6,7 +6,7 @@ import { unescapeHTML } from '../../utils/html'; | ||||
|  | ||||
| const domParser = new DOMParser(); | ||||
|  | ||||
| const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { | ||||
| const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => { | ||||
|   obj[`:${emoji.shortcode}:`] = emoji; | ||||
|   return obj; | ||||
| }, {}); | ||||
| @@ -20,7 +20,7 @@ export function searchTextFromRawStatus (status) { | ||||
| export function normalizeAccount(account) { | ||||
|   account = { ...account }; | ||||
|  | ||||
|   const emojiMap = makeEmojiMap(account); | ||||
|   const emojiMap = makeEmojiMap(account.emojis); | ||||
|   const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; | ||||
|  | ||||
|   account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); | ||||
| @@ -86,7 +86,7 @@ export function normalizeStatus(status, normalOldStatus) { | ||||
|  | ||||
|     const spoilerText   = normalStatus.spoiler_text || ''; | ||||
|     const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); | ||||
|     const emojiMap      = makeEmojiMap(normalStatus); | ||||
|     const emojiMap      = makeEmojiMap(normalStatus.emojis); | ||||
|  | ||||
|     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; | ||||
|     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap); | ||||
| @@ -97,22 +97,48 @@ export function normalizeStatus(status, normalOldStatus) { | ||||
|   return normalStatus; | ||||
| } | ||||
|  | ||||
| export function normalizeStatusTranslation(translation, status) { | ||||
|   const emojiMap = makeEmojiMap(status.get('emojis').toJS()); | ||||
|  | ||||
|   const normalTranslation = { | ||||
|     detected_source_language: translation.detected_source_language, | ||||
|     language: translation.language, | ||||
|     provider: translation.provider, | ||||
|     contentHtml: emojify(translation.content, emojiMap), | ||||
|     spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap), | ||||
|     spoiler_text: translation.spoiler_text, | ||||
|   }; | ||||
|  | ||||
|   return normalTranslation; | ||||
| } | ||||
|  | ||||
| export function normalizePoll(poll) { | ||||
|   const normalPoll = { ...poll }; | ||||
|   const emojiMap = makeEmojiMap(normalPoll); | ||||
|   const emojiMap = makeEmojiMap(poll.emojis); | ||||
|  | ||||
|   normalPoll.options = poll.options.map((option, index) => ({ | ||||
|     ...option, | ||||
|     voted: poll.own_votes && poll.own_votes.includes(index), | ||||
|     title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), | ||||
|     titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), | ||||
|   })); | ||||
|  | ||||
|   return normalPoll; | ||||
| } | ||||
|  | ||||
| export function normalizePollOptionTranslation(translation, poll) { | ||||
|   const emojiMap = makeEmojiMap(poll.get('emojis').toJS()); | ||||
|  | ||||
|   const normalTranslation = { | ||||
|     ...translation, | ||||
|     titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap), | ||||
|   }; | ||||
|  | ||||
|   return normalTranslation; | ||||
| } | ||||
|  | ||||
| export function normalizeAnnouncement(announcement) { | ||||
|   const normalAnnouncement = { ...announcement }; | ||||
|   const emojiMap = makeEmojiMap(normalAnnouncement); | ||||
|   const emojiMap = makeEmojiMap.emojis(normalAnnouncement); | ||||
|  | ||||
|   normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); | ||||
|  | ||||
|   | ||||
| @@ -343,7 +343,8 @@ export const translateStatusFail = (id, error) => ({ | ||||
|   error, | ||||
| }); | ||||
|  | ||||
| export const undoStatusTranslation = id => ({ | ||||
| export const undoStatusTranslation = (id, pollId) => ({ | ||||
|   type: STATUS_TRANSLATE_UNDO, | ||||
|   id, | ||||
|   pollId, | ||||
| }); | ||||
|   | ||||
| @@ -51,8 +51,9 @@ export default class MediaAttachments extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { status, lang, width, height } = this.props; | ||||
|     const { status, width, height } = this.props; | ||||
|     const mediaAttachments = status.get('media_attachments'); | ||||
|     const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang; | ||||
|  | ||||
|     if (mediaAttachments.size === 0) { | ||||
|       return null; | ||||
| @@ -60,14 +61,15 @@ export default class MediaAttachments extends ImmutablePureComponent { | ||||
|  | ||||
|     if (mediaAttachments.getIn([0, 'type']) === 'audio') { | ||||
|       const audio = mediaAttachments.get(0); | ||||
|       const description = audio.getIn(['translation', 'description']) || audio.get('description'); | ||||
|  | ||||
|       return ( | ||||
|         <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > | ||||
|           {Component => ( | ||||
|             <Component | ||||
|               src={audio.get('url')} | ||||
|               alt={audio.get('description')} | ||||
|               lang={lang || status.get('language')} | ||||
|               alt={description} | ||||
|               lang={language} | ||||
|               width={width} | ||||
|               height={height} | ||||
|               poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} | ||||
| @@ -81,6 +83,7 @@ export default class MediaAttachments extends ImmutablePureComponent { | ||||
|       ); | ||||
|     } else if (mediaAttachments.getIn([0, 'type']) === 'video') { | ||||
|       const video = mediaAttachments.get(0); | ||||
|       const description = video.getIn(['translation', 'description']) || video.get('description'); | ||||
|  | ||||
|       return ( | ||||
|         <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > | ||||
| @@ -90,8 +93,8 @@ export default class MediaAttachments extends ImmutablePureComponent { | ||||
|               frameRate={video.getIn(['meta', 'original', 'frame_rate'])} | ||||
|               blurhash={video.get('blurhash')} | ||||
|               src={video.get('url')} | ||||
|               alt={video.get('description')} | ||||
|               lang={lang || status.get('language')} | ||||
|               alt={description} | ||||
|               lang={language} | ||||
|               width={width} | ||||
|               height={height} | ||||
|               inline | ||||
| @@ -107,7 +110,7 @@ export default class MediaAttachments extends ImmutablePureComponent { | ||||
|           {Component => ( | ||||
|             <Component | ||||
|               media={mediaAttachments} | ||||
|               lang={lang || status.get('language')} | ||||
|               lang={language} | ||||
|               sensitive={status.get('sensitive')} | ||||
|               defaultWidth={width} | ||||
|               height={height} | ||||
|   | ||||
| @@ -105,10 +105,12 @@ class Item extends PureComponent { | ||||
|       badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>); | ||||
|     } | ||||
|  | ||||
|     const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); | ||||
|  | ||||
|     if (attachment.get('type') === 'unknown') { | ||||
|       return ( | ||||
|         <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> | ||||
|           <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'> | ||||
|           <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'> | ||||
|             <Blurhash | ||||
|               hash={attachment.get('blurhash')} | ||||
|               className='media-gallery__preview' | ||||
| @@ -146,8 +148,8 @@ class Item extends PureComponent { | ||||
|             src={previewUrl} | ||||
|             srcSet={srcSet} | ||||
|             sizes={sizes} | ||||
|             alt={attachment.get('description')} | ||||
|             title={attachment.get('description')} | ||||
|             alt={description} | ||||
|             title={description} | ||||
|             lang={lang} | ||||
|             style={{ objectPosition: `${x}% ${y}%` }} | ||||
|             onLoad={this.handleImageLoad} | ||||
| @@ -163,8 +165,8 @@ class Item extends PureComponent { | ||||
|         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | ||||
|           <video | ||||
|             className='media-gallery__item-gifv-thumbnail' | ||||
|             aria-label={attachment.get('description')} | ||||
|             title={attachment.get('description')} | ||||
|             aria-label={description} | ||||
|             title={description} | ||||
|             lang={lang} | ||||
|             role='application' | ||||
|             src={attachment.get('url')} | ||||
|   | ||||
| @@ -138,10 +138,12 @@ class Poll extends ImmutablePureComponent { | ||||
|     const active          = !!this.state.selected[`${optionIndex}`]; | ||||
|     const voted           = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); | ||||
|  | ||||
|     let titleEmojified = option.get('title_emojified'); | ||||
|     if (!titleEmojified) { | ||||
|     const title = option.getIn(['translation', 'title']) || option.get('title'); | ||||
|     let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); | ||||
|  | ||||
|     if (!titleHtml) { | ||||
|       const emojiMap = makeEmojiMap(poll); | ||||
|       titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); | ||||
|       titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
| @@ -163,7 +165,7 @@ class Poll extends ImmutablePureComponent { | ||||
|               role={poll.get('multiple') ? 'checkbox' : 'radio'} | ||||
|               onKeyPress={this.handleOptionKeyPress} | ||||
|               aria-checked={active} | ||||
|               aria-label={option.get('title')} | ||||
|               aria-label={title} | ||||
|               lang={lang} | ||||
|               data-index={optionIndex} | ||||
|             /> | ||||
| @@ -182,7 +184,7 @@ class Poll extends ImmutablePureComponent { | ||||
|           <span | ||||
|             className='poll__option__text translate' | ||||
|             lang={lang} | ||||
|             dangerouslySetInnerHTML={{ __html: titleEmojified }} | ||||
|             dangerouslySetInnerHTML={{ __html: titleHtml }} | ||||
|           /> | ||||
|  | ||||
|           {!!voted && <span className='poll__voted'> | ||||
|   | ||||
| @@ -27,12 +27,18 @@ import { RelativeTimestamp } from './relative_timestamp'; | ||||
| import StatusActionBar from './status_action_bar'; | ||||
| import StatusContent from './status_content'; | ||||
|  | ||||
| const domParser = new DOMParser(); | ||||
|  | ||||
| export const textForScreenReader = (intl, status, rebloggedByText = false) => { | ||||
|   const displayName = status.getIn(['account', 'display_name']); | ||||
|  | ||||
|   const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text'); | ||||
|   const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); | ||||
|   const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent; | ||||
|  | ||||
|   const values = [ | ||||
|     displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, | ||||
|     status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), | ||||
|     spoilerText && status.get('hidden') ? spoilerText : contentText, | ||||
|     intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), | ||||
|     status.getIn(['account', 'acct']), | ||||
|   ]; | ||||
| @@ -199,12 +205,14 @@ class Status extends ImmutablePureComponent { | ||||
|  | ||||
|   handleOpenVideo = (options) => { | ||||
|     const status = this._properStatus(); | ||||
|     this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), status.get('language'), options); | ||||
|     const lang = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|     this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options); | ||||
|   }; | ||||
|  | ||||
|   handleOpenMedia = (media, index) => { | ||||
|     const status = this._properStatus(); | ||||
|     this.props.onOpenMedia(status.get('id'), media, index, status.get('language')); | ||||
|     const lang = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|     this.props.onOpenMedia(status.get('id'), media, index, lang); | ||||
|   }; | ||||
|  | ||||
|   handleHotkeyOpenMedia = e => { | ||||
| @@ -214,7 +222,7 @@ class Status extends ImmutablePureComponent { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     if (status.get('media_attachments').size > 0) { | ||||
|       const lang = status.get('language'); | ||||
|       const lang = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||
|         onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); | ||||
|       } else { | ||||
| @@ -420,6 +428,8 @@ class Status extends ImmutablePureComponent { | ||||
|     if (pictureInPicture.get('inUse')) { | ||||
|       media = <PictureInPicturePlaceholder />; | ||||
|     } else if (status.get('media_attachments').size > 0) { | ||||
|       const language = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|  | ||||
|       if (this.props.muted) { | ||||
|         media = ( | ||||
|           <AttachmentList | ||||
| @@ -429,14 +439,15 @@ class Status extends ImmutablePureComponent { | ||||
|         ); | ||||
|       } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { | ||||
|         const attachment = status.getIn(['media_attachments', 0]); | ||||
|         const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); | ||||
|  | ||||
|         media = ( | ||||
|           <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > | ||||
|             {Component => ( | ||||
|               <Component | ||||
|                 src={attachment.get('url')} | ||||
|                 alt={attachment.get('description')} | ||||
|                 lang={status.get('language')} | ||||
|                 alt={description} | ||||
|                 lang={language} | ||||
|                 poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} | ||||
|                 backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} | ||||
|                 foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} | ||||
| @@ -456,6 +467,7 @@ class Status extends ImmutablePureComponent { | ||||
|         ); | ||||
|       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||
|         const attachment = status.getIn(['media_attachments', 0]); | ||||
|         const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); | ||||
|  | ||||
|         media = ( | ||||
|           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > | ||||
| @@ -465,8 +477,8 @@ class Status extends ImmutablePureComponent { | ||||
|                 frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} | ||||
|                 blurhash={attachment.get('blurhash')} | ||||
|                 src={attachment.get('url')} | ||||
|                 alt={attachment.get('description')} | ||||
|                 lang={status.get('language')} | ||||
|                 alt={description} | ||||
|                 lang={language} | ||||
|                 inline | ||||
|                 sensitive={status.get('sensitive')} | ||||
|                 onOpenVideo={this.handleOpenVideo} | ||||
| @@ -483,7 +495,7 @@ class Status extends ImmutablePureComponent { | ||||
|             {Component => ( | ||||
|               <Component | ||||
|                 media={status.get('media_attachments')} | ||||
|                 lang={status.get('language')} | ||||
|                 lang={language} | ||||
|                 sensitive={status.get('sensitive')} | ||||
|                 height={110} | ||||
|                 onOpenMedia={this.handleOpenMedia} | ||||
|   | ||||
| @@ -231,11 +231,11 @@ class StatusContent extends PureComponent { | ||||
|     const renderReadMore = this.props.onClick && status.get('collapsed'); | ||||
|     const contentLocale = intl.locale.replace(/[_-].*/, ''); | ||||
|     const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); | ||||
|     const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && targetLanguages?.includes(contentLocale); | ||||
|     const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); | ||||
|  | ||||
|     const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') }; | ||||
|     const spoilerContent = { __html: status.get('spoilerHtml') }; | ||||
|     const lang = status.get('translation') ? intl.locale : status.get('language'); | ||||
|     const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') }; | ||||
|     const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; | ||||
|     const language = status.getIn(['translation', 'language']) || 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, | ||||
| @@ -253,7 +253,7 @@ class StatusContent extends PureComponent { | ||||
|     ); | ||||
|  | ||||
|     const poll = !!status.get('poll') && ( | ||||
|       <PollContainer pollId={status.get('poll')} lang={status.get('language')} /> | ||||
|       <PollContainer pollId={status.get('poll')} lang={language} /> | ||||
|     ); | ||||
|  | ||||
|     if (status.get('spoiler_text').length > 0) { | ||||
| @@ -274,24 +274,24 @@ class StatusContent extends PureComponent { | ||||
|       return ( | ||||
|         <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | ||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||
|             <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} /> | ||||
|             <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} /> | ||||
|             {' '} | ||||
|             <button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button> | ||||
|           </p> | ||||
|  | ||||
|           {mentionsPlaceholder} | ||||
|  | ||||
|           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} /> | ||||
|           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={language} dangerouslySetInnerHTML={content} /> | ||||
|  | ||||
|           {!hidden && poll} | ||||
|           {!hidden && translateButton} | ||||
|           {translateButton} | ||||
|         </div> | ||||
|       ); | ||||
|     } else if (this.props.onClick) { | ||||
|       return ( | ||||
|         <> | ||||
|           <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | ||||
|             <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> | ||||
|             <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> | ||||
|  | ||||
|             {poll} | ||||
|             {translateButton} | ||||
| @@ -303,7 +303,7 @@ class StatusContent extends PureComponent { | ||||
|     } else { | ||||
|       return ( | ||||
|         <div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | ||||
|           <div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> | ||||
|           <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} /> | ||||
|  | ||||
|           {poll} | ||||
|           {translateButton} | ||||
|   | ||||
| @@ -180,7 +180,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ | ||||
|  | ||||
|   onTranslate (status) { | ||||
|     if (status.get('translation')) { | ||||
|       dispatch(undoStatusTranslation(status.get('id'))); | ||||
|       dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); | ||||
|     } else { | ||||
|       dispatch(translateStatus(status.get('id'))); | ||||
|     } | ||||
|   | ||||
| @@ -133,17 +133,20 @@ class DetailedStatus extends ImmutablePureComponent { | ||||
|       outerStyle.height = `${this.state.height}px`; | ||||
|     } | ||||
|  | ||||
|     const language = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|  | ||||
|     if (pictureInPicture.get('inUse')) { | ||||
|       media = <PictureInPicturePlaceholder />; | ||||
|     } else if (status.get('media_attachments').size > 0) { | ||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { | ||||
|         const attachment = status.getIn(['media_attachments', 0]); | ||||
|         const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); | ||||
|  | ||||
|         media = ( | ||||
|           <Audio | ||||
|             src={attachment.get('url')} | ||||
|             alt={attachment.get('description')} | ||||
|             lang={status.get('language')} | ||||
|             alt={description} | ||||
|             lang={language} | ||||
|             duration={attachment.getIn(['meta', 'original', 'duration'], 0)} | ||||
|             poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} | ||||
|             backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} | ||||
| @@ -158,6 +161,7 @@ class DetailedStatus extends ImmutablePureComponent { | ||||
|         ); | ||||
|       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | ||||
|         const attachment = status.getIn(['media_attachments', 0]); | ||||
|         const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); | ||||
|  | ||||
|         media = ( | ||||
|           <Video | ||||
| @@ -165,8 +169,8 @@ class DetailedStatus extends ImmutablePureComponent { | ||||
|             frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} | ||||
|             blurhash={attachment.get('blurhash')} | ||||
|             src={attachment.get('url')} | ||||
|             alt={attachment.get('description')} | ||||
|             lang={status.get('language')} | ||||
|             alt={description} | ||||
|             lang={language} | ||||
|             width={300} | ||||
|             height={150} | ||||
|             inline | ||||
| @@ -182,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent { | ||||
|             standalone | ||||
|             sensitive={status.get('sensitive')} | ||||
|             media={status.get('media_attachments')} | ||||
|             lang={status.get('language')} | ||||
|             lang={language} | ||||
|             height={300} | ||||
|             onOpenMedia={this.props.onOpenMedia} | ||||
|             visible={this.props.showMedia} | ||||
|   | ||||
| @@ -430,7 +430,7 @@ class Status extends ImmutablePureComponent { | ||||
|     const { dispatch } = this.props; | ||||
|  | ||||
|     if (status.get('translation')) { | ||||
|       dispatch(undoStatusTranslation(status.get('id'))); | ||||
|       dispatch(undoStatusTranslation(status.get('id'), status.get('poll'))); | ||||
|     } else { | ||||
|       dispatch(translateStatus(status.get('id'))); | ||||
|     } | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import Audio from 'mastodon/features/audio'; | ||||
| import Footer from 'mastodon/features/picture_in_picture/components/footer'; | ||||
|  | ||||
| const mapStateToProps = (state, { statusId }) => ({ | ||||
|   language: state.getIn(['statuses', statusId, 'language']), | ||||
|   status: state.getIn(['statuses', statusId]), | ||||
|   accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), | ||||
| }); | ||||
|  | ||||
| @@ -17,7 +17,7 @@ class AudioModal extends ImmutablePureComponent { | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     statusId: PropTypes.string.isRequired, | ||||
|     language: PropTypes.string, | ||||
|     status: ImmutablePropTypes.map.isRequired, | ||||
|     accountStaticAvatar: PropTypes.string.isRequired, | ||||
|     options: PropTypes.shape({ | ||||
|       autoPlay: PropTypes.bool, | ||||
| @@ -27,15 +27,17 @@ class AudioModal extends ImmutablePureComponent { | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { media, language, accountStaticAvatar, statusId, onClose } = this.props; | ||||
|     const { media, status, accountStaticAvatar, onClose } = this.props; | ||||
|     const options = this.props.options || {}; | ||||
|     const language = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|     const description = media.getIn(['translation', 'description']) || media.get('description'); | ||||
|  | ||||
|     return ( | ||||
|       <div className='modal-root__modal audio-modal'> | ||||
|         <div className='audio-modal__container'> | ||||
|           <Audio | ||||
|             src={media.get('url')} | ||||
|             alt={media.get('description')} | ||||
|             alt={description} | ||||
|             lang={language} | ||||
|             duration={media.getIn(['meta', 'original', 'duration'], 0)} | ||||
|             height={150} | ||||
| @@ -48,7 +50,7 @@ class AudioModal extends ImmutablePureComponent { | ||||
|         </div> | ||||
|  | ||||
|         <div className='media-modal__overlay'> | ||||
|           {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} | ||||
|           {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -145,6 +145,7 @@ class MediaModal extends ImmutablePureComponent { | ||||
|     const content = media.map((image) => { | ||||
|       const width  = image.getIn(['meta', 'original', 'width']) || null; | ||||
|       const height = image.getIn(['meta', 'original', 'height']) || null; | ||||
|       const description = image.getIn(['translation', 'description']) || image.get('description'); | ||||
|  | ||||
|       if (image.get('type') === 'image') { | ||||
|         return ( | ||||
| @@ -153,7 +154,7 @@ class MediaModal extends ImmutablePureComponent { | ||||
|             src={image.get('url')} | ||||
|             width={width} | ||||
|             height={height} | ||||
|             alt={image.get('description')} | ||||
|             alt={description} | ||||
|             lang={lang} | ||||
|             key={image.get('url')} | ||||
|             onClick={this.toggleNavigation} | ||||
| @@ -176,7 +177,7 @@ class MediaModal extends ImmutablePureComponent { | ||||
|             volume={volume || 1} | ||||
|             onCloseVideo={onClose} | ||||
|             detailed | ||||
|             alt={image.get('description')} | ||||
|             alt={description} | ||||
|             lang={lang} | ||||
|             key={image.get('url')} | ||||
|           /> | ||||
| @@ -188,7 +189,7 @@ class MediaModal extends ImmutablePureComponent { | ||||
|             width={width} | ||||
|             height={height} | ||||
|             key={image.get('url')} | ||||
|             alt={image.get('description')} | ||||
|             alt={description} | ||||
|             lang={lang} | ||||
|             onClick={this.toggleNavigation} | ||||
|           /> | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer'; | ||||
| import Video from 'mastodon/features/video'; | ||||
|  | ||||
| const mapStateToProps = (state, { statusId }) => ({ | ||||
|   language: state.getIn(['statuses', statusId, 'language']), | ||||
|   status: state.getIn(['statuses', statusId]), | ||||
| }); | ||||
|  | ||||
| class VideoModal extends ImmutablePureComponent { | ||||
| @@ -17,7 +17,7 @@ class VideoModal extends ImmutablePureComponent { | ||||
|   static propTypes = { | ||||
|     media: ImmutablePropTypes.map.isRequired, | ||||
|     statusId: PropTypes.string, | ||||
|     language: PropTypes.string, | ||||
|     status: ImmutablePropTypes.map, | ||||
|     options: PropTypes.shape({ | ||||
|       startTime: PropTypes.number, | ||||
|       autoPlay: PropTypes.bool, | ||||
| @@ -38,8 +38,10 @@ class VideoModal extends ImmutablePureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { media, statusId, language, onClose } = this.props; | ||||
|     const { media, status, onClose } = this.props; | ||||
|     const options = this.props.options || {}; | ||||
|     const language = status.getIn(['translation', 'language']) || status.get('language'); | ||||
|     const description = media.getIn(['translation', 'description']) || media.get('description'); | ||||
|  | ||||
|     return ( | ||||
|       <div className='modal-root__modal video-modal'> | ||||
| @@ -55,13 +57,13 @@ class VideoModal extends ImmutablePureComponent { | ||||
|             onCloseVideo={onClose} | ||||
|             autoFocus | ||||
|             detailed | ||||
|             alt={media.get('description')} | ||||
|             alt={description} | ||||
|             lang={language} | ||||
|           /> | ||||
|         </div> | ||||
|  | ||||
|         <div className='media-modal__overlay'> | ||||
|           {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} | ||||
|           {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />} | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -2,14 +2,43 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||
|  | ||||
| import { POLLS_IMPORT } from 'mastodon/actions/importer'; | ||||
|  | ||||
| import { normalizePollOptionTranslation } from '../actions/importer/normalizer'; | ||||
| import { STATUS_TRANSLATE_SUCCESS, STATUS_TRANSLATE_UNDO } from '../actions/statuses'; | ||||
|  | ||||
| const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); | ||||
|  | ||||
| const statusTranslateSuccess = (state, pollTranslation) => { | ||||
|   return state.withMutations(map => { | ||||
|     if (pollTranslation) { | ||||
|       const poll = state.get(pollTranslation.id); | ||||
|  | ||||
|       pollTranslation.options.forEach((item, index) => { | ||||
|         map.setIn([pollTranslation.id, 'options', index, 'translation'], fromJS(normalizePollOptionTranslation(item, poll))); | ||||
|       }); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const statusTranslateUndo = (state, id) => { | ||||
|   return state.withMutations(map => { | ||||
|     const options = map.getIn([id, 'options']); | ||||
|  | ||||
|     if (options) { | ||||
|       options.forEach((item, index) => map.deleteIn([id, 'options', index, 'translation'])); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const initialState = ImmutableMap(); | ||||
|  | ||||
| export default function polls(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case POLLS_IMPORT: | ||||
|     return importPolls(state, action.polls); | ||||
|   case STATUS_TRANSLATE_SUCCESS: | ||||
|     return statusTranslateSuccess(state, action.translation.poll); | ||||
|   case STATUS_TRANSLATE_UNDO: | ||||
|     return statusTranslateUndo(state, action.pollId); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { Map as ImmutableMap, fromJS } from 'immutable'; | ||||
|  | ||||
| import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; | ||||
| import { normalizeStatusTranslation } from '../actions/importer/normalizer'; | ||||
| import { | ||||
|   REBLOG_REQUEST, | ||||
|   REBLOG_FAIL, | ||||
| @@ -36,6 +37,27 @@ const deleteStatus = (state, id, references) => { | ||||
|   return state.delete(id); | ||||
| }; | ||||
|  | ||||
| const statusTranslateSuccess = (state, id, translation) => { | ||||
|   return state.withMutations(map => { | ||||
|     map.setIn([id, 'translation'], fromJS(normalizeStatusTranslation(translation, map.get(id)))); | ||||
|  | ||||
|     const list = map.getIn([id, 'media_attachments']); | ||||
|     if (translation.media_attachments && list) { | ||||
|       translation.media_attachments.forEach(item => { | ||||
|         const index = list.findIndex(i => i.get('id') === item.id); | ||||
|         map.setIn([id, 'media_attachments', index, 'translation'], fromJS({ description: item.description })); | ||||
|       }); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const statusTranslateUndo = (state, id) => { | ||||
|   return state.withMutations(map => { | ||||
|     map.deleteIn([id, 'translation']); | ||||
|     map.getIn([id, 'media_attachments']).forEach((item, index) => map.deleteIn([id, 'media_attachments', index, 'translation'])); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const initialState = ImmutableMap(); | ||||
|  | ||||
| export default function statuses(state = initialState, action) { | ||||
| @@ -87,9 +109,9 @@ export default function statuses(state = initialState, action) { | ||||
|   case TIMELINE_DELETE: | ||||
|     return deleteStatus(state, action.id, action.references); | ||||
|   case STATUS_TRANSLATE_SUCCESS: | ||||
|     return state.setIn([action.id, 'translation'], fromJS(action.translation)); | ||||
|     return statusTranslateSuccess(state, action.id, action.translation); | ||||
|   case STATUS_TRANSLATE_UNDO: | ||||
|     return state.deleteIn([action.id, 'translation']); | ||||
|     return statusTranslateUndo(state, action.id); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ class EmojiFormatter | ||||
|   # @param [Hash] options | ||||
|   # @option options [Boolean] :animate | ||||
|   # @option options [String] :style | ||||
|   # @option options [String] :raw_shortcode | ||||
|   def initialize(html, custom_emojis, options = {}) | ||||
|     raise ArgumentError unless html.html_safe? | ||||
|  | ||||
| @@ -43,7 +44,7 @@ class EmojiFormatter | ||||
|           next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode]) | ||||
|  | ||||
|           result << Nokogiri::XML::Text.new(text[last_index..shortname_start_index - 1], tree.document) if shortname_start_index.positive? | ||||
|           result << Nokogiri::HTML.fragment(image_for_emoji(shortcode, emoji)) | ||||
|           result << Nokogiri::HTML.fragment(tag_for_emoji(shortcode, emoji)) | ||||
|  | ||||
|           last_index = i + 1 | ||||
|         elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1])) | ||||
| @@ -75,7 +76,9 @@ class EmojiFormatter | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def image_for_emoji(shortcode, emoji) | ||||
|   def tag_for_emoji(shortcode, emoji) | ||||
|     return content_tag(:span, ":#{shortcode}:", translate: 'no') if raw_shortcode? | ||||
|  | ||||
|     original_url, static_url = emoji | ||||
|  | ||||
|     image_tag( | ||||
| @@ -103,4 +106,8 @@ class EmojiFormatter | ||||
|   def animate? | ||||
|     @options[:animate] || @options.key?(:style) | ||||
|   end | ||||
|  | ||||
|   def raw_shortcode? | ||||
|     @options[:raw_shortcode] | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -10,8 +10,8 @@ class TranslationService::DeepL < TranslationService | ||||
|     @api_key = api_key | ||||
|   end | ||||
|  | ||||
|   def translate(text, source_language, target_language) | ||||
|     form = { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' } | ||||
|   def translate(texts, source_language, target_language) | ||||
|     form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' } | ||||
|     request(:post, '/v2/translate', form: form) do |res| | ||||
|       transform_response(res.body_with_limit) | ||||
|     end | ||||
| @@ -67,12 +67,17 @@ class TranslationService::DeepL < TranslationService | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def transform_response(str) | ||||
|     json = Oj.load(str, mode: :strict) | ||||
|   def transform_response(json) | ||||
|     data = Oj.load(json, mode: :strict) | ||||
|     raise UnexpectedResponseError unless data.is_a?(Hash) | ||||
|  | ||||
|     raise UnexpectedResponseError unless json.is_a?(Hash) | ||||
|  | ||||
|     Translation.new(text: json.dig('translations', 0, 'text'), detected_source_language: json.dig('translations', 0, 'detected_source_language')&.downcase, provider: 'DeepL.com') | ||||
|     data['translations'].map do |translation| | ||||
|       Translation.new( | ||||
|         text: translation['text'], | ||||
|         detected_source_language: translation['detected_source_language']&.downcase, | ||||
|         provider: 'DeepL.com' | ||||
|       ) | ||||
|     end | ||||
|   rescue Oj::ParseError | ||||
|     raise UnexpectedResponseError | ||||
|   end | ||||
|   | ||||
| @@ -8,8 +8,8 @@ class TranslationService::LibreTranslate < TranslationService | ||||
|     @api_key  = api_key | ||||
|   end | ||||
|  | ||||
|   def translate(text, source_language, target_language) | ||||
|     body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) | ||||
|   def translate(texts, source_language, target_language) | ||||
|     body = Oj.dump(q: texts, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key) | ||||
|     request(:post, '/translate', body: body) do |res| | ||||
|       transform_response(res.body_with_limit, source_language) | ||||
|     end | ||||
| @@ -44,12 +44,17 @@ class TranslationService::LibreTranslate < TranslationService | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def transform_response(str, source_language) | ||||
|     json = Oj.load(str, mode: :strict) | ||||
|   def transform_response(json, source_language) | ||||
|     data = Oj.load(json, mode: :strict) | ||||
|     raise UnexpectedResponseError unless data.is_a?(Hash) | ||||
|  | ||||
|     raise UnexpectedResponseError unless json.is_a?(Hash) | ||||
|  | ||||
|     Translation.new(text: json['translatedText'], detected_source_language: source_language, provider: 'LibreTranslate') | ||||
|     data['translatedText'].map.with_index do |text, index| | ||||
|       Translation.new( | ||||
|         text: text, | ||||
|         detected_source_language: data.dig('detectedLanguage', index, 'language') || source_language, | ||||
|         provider: 'LibreTranslate' | ||||
|       ) | ||||
|     end | ||||
|   rescue Oj::ParseError | ||||
|     raise UnexpectedResponseError | ||||
|   end | ||||
|   | ||||
							
								
								
									
										14
									
								
								app/models/translation.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/models/translation.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Translation < ActiveModelSerializers::Model | ||||
|   attributes :status, :detected_source_language, :language, :provider, | ||||
|              :content, :spoiler_text, :poll_options, :media_attachments | ||||
|  | ||||
|   class Option < ActiveModelSerializers::Model | ||||
|     attributes :title | ||||
|   end | ||||
|  | ||||
|   class MediaAttachment < ActiveModelSerializers::Model | ||||
|     attributes :id, :description | ||||
|   end | ||||
| end | ||||
| @@ -1,9 +1,38 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class REST::TranslationSerializer < ActiveModel::Serializer | ||||
|   attributes :content, :detected_source_language, :provider | ||||
|   attributes :detected_source_language, :language, :provider, :spoiler_text, :content | ||||
|  | ||||
|   def content | ||||
|     object.text | ||||
|   class PollSerializer < ActiveModel::Serializer | ||||
|     attribute :id | ||||
|     has_many :options | ||||
|  | ||||
|     def id | ||||
|       object.status.preloadable_poll.id.to_s | ||||
|     end | ||||
|  | ||||
|     def options | ||||
|       object.poll_options | ||||
|     end | ||||
|  | ||||
|     class OptionSerializer < ActiveModel::Serializer | ||||
|       attributes :title | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   has_one :poll, serializer: PollSerializer | ||||
|  | ||||
|   class MediaAttachmentSerializer < ActiveModel::Serializer | ||||
|     attributes :id, :description | ||||
|  | ||||
|     def id | ||||
|       object.id.to_s | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   has_many :media_attachments, serializer: MediaAttachmentSerializer | ||||
|  | ||||
|   def poll | ||||
|     object if object.status.preloadable_poll | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,16 +3,24 @@ | ||||
| class TranslateStatusService < BaseService | ||||
|   CACHE_TTL = 1.day.freeze | ||||
|  | ||||
|   include ERB::Util | ||||
|   include FormattingHelper | ||||
|  | ||||
|   def call(status, target_language) | ||||
|     @status = status | ||||
|     @content = status_content_format(@status) | ||||
|     @source_texts = source_texts | ||||
|     @target_language = target_language | ||||
|  | ||||
|     raise Mastodon::NotPermittedError unless permitted? | ||||
|  | ||||
|     Rails.cache.fetch("translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) { translation_backend.translate(@content, @status.language, @target_language) } | ||||
|     status_translation = Rails.cache.fetch("v2:translations/#{@status.language}/#{@target_language}/#{content_hash}", expires_in: CACHE_TTL) do | ||||
|       translations = translation_backend.translate(@source_texts.values, @status.language, @target_language) | ||||
|       build_status_translation(translations) | ||||
|     end | ||||
|  | ||||
|     status_translation.status = @status | ||||
|  | ||||
|     status_translation | ||||
|   end | ||||
|  | ||||
|   private | ||||
| @@ -22,7 +30,7 @@ class TranslateStatusService < BaseService | ||||
|   end | ||||
|  | ||||
|   def permitted? | ||||
|     return false unless @status.distributable? && @status.content.present? && TranslationService.configured? | ||||
|     return false unless @status.distributable? && TranslationService.configured? | ||||
|  | ||||
|     languages[@status.language]&.include?(@target_language) | ||||
|   end | ||||
| @@ -32,6 +40,73 @@ class TranslateStatusService < BaseService | ||||
|   end | ||||
|  | ||||
|   def content_hash | ||||
|     Digest::SHA256.base64digest(@content) | ||||
|     Digest::SHA256.base64digest(@source_texts.transform_keys { |key| key.respond_to?(:id) ? "#{key.class}-#{key.id}" : key }.to_json) | ||||
|   end | ||||
|  | ||||
|   def source_texts | ||||
|     texts = {} | ||||
|     texts[:content] = wrap_emoji_shortcodes(status_content_format(@status)) if @status.content.present? | ||||
|     texts[:spoiler_text] = wrap_emoji_shortcodes(html_escape(@status.spoiler_text)) if @status.spoiler_text.present? | ||||
|  | ||||
|     @status.preloadable_poll&.loaded_options&.each do |option| | ||||
|       texts[option] = wrap_emoji_shortcodes(html_escape(option.title)) | ||||
|     end | ||||
|  | ||||
|     @status.media_attachments.each do |media_attachment| | ||||
|       texts[media_attachment] = html_escape(media_attachment.description) | ||||
|     end | ||||
|  | ||||
|     texts | ||||
|   end | ||||
|  | ||||
|   def build_status_translation(translations) | ||||
|     status_translation = Translation.new( | ||||
|       detected_source_language: translations.first&.detected_source_language, | ||||
|       language: @target_language, | ||||
|       provider: translations.first&.provider, | ||||
|       content: '', | ||||
|       spoiler_text: '', | ||||
|       poll_options: [], | ||||
|       media_attachments: [] | ||||
|     ) | ||||
|  | ||||
|     @source_texts.keys.each_with_index do |source, index| | ||||
|       translation = translations[index] | ||||
|  | ||||
|       case source | ||||
|       when :content | ||||
|         status_translation.content = unwrap_emoji_shortcodes(translation.text).to_html | ||||
|       when :spoiler_text | ||||
|         status_translation.spoiler_text = unwrap_emoji_shortcodes(translation.text).content | ||||
|       when Poll::Option | ||||
|         status_translation.poll_options << Translation::Option.new( | ||||
|           title: unwrap_emoji_shortcodes(translation.text).content | ||||
|         ) | ||||
|       when MediaAttachment | ||||
|         status_translation.media_attachments << Translation::MediaAttachment.new( | ||||
|           id: source.id, | ||||
|           description: html_entities.decode(translation.text) | ||||
|         ) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     status_translation | ||||
|   end | ||||
|  | ||||
|   def wrap_emoji_shortcodes(text) | ||||
|     EmojiFormatter.new(text, @status.emojis, { raw_shortcode: true }).to_s | ||||
|   end | ||||
|  | ||||
|   def unwrap_emoji_shortcodes(html) | ||||
|     fragment = Nokogiri::HTML.fragment(html) | ||||
|     fragment.css('span[translate="no"]').each do |element| | ||||
|       element.remove_attribute('translate') | ||||
|       element.replace(element.children) if element.attributes.empty? | ||||
|     end | ||||
|     fragment | ||||
|   end | ||||
|  | ||||
|   def html_entities | ||||
|     HTMLEntities.new | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -19,7 +19,7 @@ describe Api::V1::Statuses::TranslationsController do | ||||
|  | ||||
|       before do | ||||
|         translation = TranslationService::Translation.new(text: 'Hello') | ||||
|         service = instance_double(TranslationService::DeepL, translate: translation) | ||||
|         service = instance_double(TranslationService::DeepL, translate: [translation]) | ||||
|         allow(TranslationService).to receive(:configured?).and_return(true) | ||||
|         allow(TranslationService).to receive(:configured).and_return(service) | ||||
|         Rails.cache.write('translation_service/languages', { 'es' => ['en'] }) | ||||
|   | ||||
| @@ -22,7 +22,10 @@ RSpec.describe TranslationService::DeepL do | ||||
|         .with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html') | ||||
|         .to_return(body: '{"translations":[{"detected_source_language":"ES","text":"See you soon"}]}') | ||||
|  | ||||
|       translation = service.translate('Hasta la vista', 'es', 'en') | ||||
|       translations = service.translate(['Hasta la vista'], 'es', 'en') | ||||
|       expect(translations.size).to eq 1 | ||||
|  | ||||
|       translation = translations.first | ||||
|       expect(translation.detected_source_language).to eq 'es' | ||||
|       expect(translation.provider).to eq 'DeepL.com' | ||||
|       expect(translation.text).to eq 'See you soon' | ||||
| @@ -31,12 +34,27 @@ RSpec.describe TranslationService::DeepL do | ||||
|     it 'returns translation with auto-detected source language' do | ||||
|       stub_request(:post, 'https://api.deepl.com/v2/translate') | ||||
|         .with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html') | ||||
|         .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good Morning"}]}') | ||||
|         .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"}]}') | ||||
|  | ||||
|       translation = service.translate('Guten Tag', nil, 'en') | ||||
|       translations = service.translate(['Guten Tag'], nil, 'en') | ||||
|       expect(translations.size).to eq 1 | ||||
|  | ||||
|       translation = translations.first | ||||
|       expect(translation.detected_source_language).to eq 'de' | ||||
|       expect(translation.provider).to eq 'DeepL.com' | ||||
|       expect(translation.text).to eq 'Good Morning' | ||||
|       expect(translation.text).to eq 'Good morning' | ||||
|     end | ||||
|  | ||||
|     it 'returns translation of multiple texts' do | ||||
|       stub_request(:post, 'https://api.deepl.com/v2/translate') | ||||
|         .with(body: 'text=Guten+Morgen&text=Gute+Nacht&source_lang=DE&target_lang=en&tag_handling=html') | ||||
|         .to_return(body: '{"translations":[{"detected_source_language":"DE","text":"Good morning"},{"detected_source_language":"DE","text":"Good night"}]}') | ||||
|  | ||||
|       translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') | ||||
|       expect(translations.size).to eq 2 | ||||
|  | ||||
|       expect(translations.first.text).to eq 'Good morning' | ||||
|       expect(translations.last.text).to eq 'Good night' | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -31,24 +31,42 @@ RSpec.describe TranslationService::LibreTranslate do | ||||
|   describe '#translate' do | ||||
|     it 'returns translation with specified source language' do | ||||
|       stub_request(:post, 'https://libretranslate.example.com/translate') | ||||
|         .with(body: '{"q":"Hasta la vista","source":"es","target":"en","format":"html","api_key":"my-api-key"}') | ||||
|         .to_return(body: '{"translatedText": "See you"}') | ||||
|         .with(body: '{"q":["Hasta la vista"],"source":"es","target":"en","format":"html","api_key":"my-api-key"}') | ||||
|         .to_return(body: '{"translatedText": ["See you"]}') | ||||
|  | ||||
|       translation = service.translate('Hasta la vista', 'es', 'en') | ||||
|       expect(translation.detected_source_language).to eq 'es' | ||||
|       translations = service.translate(['Hasta la vista'], 'es', 'en') | ||||
|       expect(translations.size).to eq 1 | ||||
|  | ||||
|       translation = translations.first | ||||
|       expect(translation.detected_source_language).to be 'es' | ||||
|       expect(translation.provider).to eq 'LibreTranslate' | ||||
|       expect(translation.text).to eq 'See you' | ||||
|     end | ||||
|  | ||||
|     it 'returns translation with auto-detected source language' do | ||||
|       stub_request(:post, 'https://libretranslate.example.com/translate') | ||||
|         .with(body: '{"q":"Guten Morgen","source":"auto","target":"en","format":"html","api_key":"my-api-key"}') | ||||
|         .to_return(body: '{"detectedLanguage":{"confidence":92,"language":"de"},"translatedText":"Good morning"}') | ||||
|         .with(body: '{"q":["Guten Morgen"],"source":"auto","target":"en","format":"html","api_key":"my-api-key"}') | ||||
|         .to_return(body: '{"detectedLanguage": [{"confidence": 92, "language": "de"}], "translatedText": ["Good morning"]}') | ||||
|  | ||||
|       translation = service.translate('Guten Morgen', nil, 'en') | ||||
|       expect(translation.detected_source_language).to be_nil | ||||
|       translations = service.translate(['Guten Morgen'], nil, 'en') | ||||
|       expect(translations.size).to eq 1 | ||||
|  | ||||
|       translation = translations.first | ||||
|       expect(translation.detected_source_language).to eq 'de' | ||||
|       expect(translation.provider).to eq 'LibreTranslate' | ||||
|       expect(translation.text).to eq 'Good morning' | ||||
|     end | ||||
|  | ||||
|     it 'returns translation of multiple texts' do | ||||
|       stub_request(:post, 'https://libretranslate.example.com/translate') | ||||
|         .with(body: '{"q":["Guten Morgen","Gute Nacht"],"source":"de","target":"en","format":"html","api_key":"my-api-key"}') | ||||
|         .to_return(body: '{"translatedText": ["Good morning", "Good night"]}') | ||||
|  | ||||
|       translations = service.translate(['Guten Morgen', 'Gute Nacht'], 'de', 'en') | ||||
|       expect(translations.size).to eq 2 | ||||
|  | ||||
|       expect(translations.first.text).to eq 'Good morning' | ||||
|       expect(translations.last.text).to eq 'Good night' | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										226
									
								
								spec/services/translate_status_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								spec/services/translate_status_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe TranslateStatusService, type: :service do | ||||
|   subject(:service) { described_class.new } | ||||
|  | ||||
|   let(:status) { Fabricate(:status, text: text, spoiler_text: spoiler_text, language: 'en', preloadable_poll: poll, media_attachments: media_attachments) } | ||||
|   let(:text) { 'Hello' } | ||||
|   let(:spoiler_text) { '' } | ||||
|   let(:poll) { nil } | ||||
|   let(:media_attachments) { [] } | ||||
|  | ||||
|   before do | ||||
|     Fabricate(:custom_emoji, shortcode: 'highfive') | ||||
|   end | ||||
|  | ||||
|   describe '#call' do | ||||
|     before do | ||||
|       translation_service = TranslationService.new | ||||
|       allow(translation_service).to receive(:languages).and_return({ 'en' => ['es'] }) | ||||
|       allow(translation_service).to receive(:translate) do |texts| | ||||
|         texts.map do |text| | ||||
|           TranslationService::Translation.new( | ||||
|             text: text.gsub('Hello', 'Hola').gsub('higfive', 'cincoaltos'), | ||||
|             detected_source_language: 'en', | ||||
|             provider: 'Dummy' | ||||
|           ) | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       allow(TranslationService).to receive(:configured?).and_return(true) | ||||
|       allow(TranslationService).to receive(:configured).and_return(translation_service) | ||||
|     end | ||||
|  | ||||
|     it 'returns translated status content' do | ||||
|       expect(service.call(status, 'es').content).to eq '<p>Hola</p>' | ||||
|     end | ||||
|  | ||||
|     it 'returns source language' do | ||||
|       expect(service.call(status, 'es').detected_source_language).to eq 'en' | ||||
|     end | ||||
|  | ||||
|     it 'returns translation provider' do | ||||
|       expect(service.call(status, 'es').provider).to eq 'Dummy' | ||||
|     end | ||||
|  | ||||
|     it 'returns original status' do | ||||
|       expect(service.call(status, 'es').status).to eq status | ||||
|     end | ||||
|  | ||||
|     describe 'status has content with custom emoji' do | ||||
|       let(:text) { 'Hello & :highfive:' } | ||||
|  | ||||
|       it 'does not translate shortcode' do | ||||
|         expect(service.call(status, 'es').content).to eq '<p>Hola & :highfive:</p>' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has no spoiler_text' do | ||||
|       it 'returns an empty string' do | ||||
|         expect(service.call(status, 'es').spoiler_text).to eq '' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has spoiler_text' do | ||||
|       let(:spoiler_text) { 'Hello & Hello!' } | ||||
|  | ||||
|       it 'translates the spoiler text' do | ||||
|         expect(service.call(status, 'es').spoiler_text).to eq 'Hola & Hola!' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has spoiler_text with custom emoji' do | ||||
|       let(:spoiler_text) { 'Hello :highfive:' } | ||||
|  | ||||
|       it 'does not translate shortcode' do | ||||
|         expect(service.call(status, 'es').spoiler_text).to eq 'Hola :highfive:' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has spoiler_text with unmatched custom emoji' do | ||||
|       let(:spoiler_text) { 'Hello :Hello:' } | ||||
|  | ||||
|       it 'translates the invalid shortcode' do | ||||
|         expect(service.call(status, 'es').spoiler_text).to eq 'Hola :Hola:' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has poll' do | ||||
|       let(:poll) { Fabricate(:poll, options: ['Hello 1', 'Hello 2']) } | ||||
|  | ||||
|       it 'translates the poll option title' do | ||||
|         status_translation = service.call(status, 'es') | ||||
|         expect(status_translation.poll_options.size).to eq 2 | ||||
|         expect(status_translation.poll_options.first.title).to eq 'Hola 1' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has media attachment' do | ||||
|       let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello & :highfive:')] } | ||||
|  | ||||
|       it 'translates the media attachment description' do | ||||
|         status_translation = service.call(status, 'es') | ||||
|  | ||||
|         media_attachment = status_translation.media_attachments.first | ||||
|         expect(media_attachment.id).to eq media_attachments.first.id | ||||
|         expect(media_attachment.description).to eq 'Hola & :highfive:' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#source_texts' do | ||||
|     before do | ||||
|       service.instance_variable_set(:@status, status) | ||||
|     end | ||||
|  | ||||
|     describe 'status only has content' do | ||||
|       it 'returns formatted content' do | ||||
|         expect(service.send(:source_texts)).to eq({ content: '<p>Hello</p>' }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status content contains custom emoji' do | ||||
|       let(:status) { Fabricate(:status, text: 'Hello :highfive:') } | ||||
|  | ||||
|       it 'returns formatted content' do | ||||
|         source_texts = service.send(:source_texts) | ||||
|         expect(source_texts[:content]).to eq '<p>Hello <span translate="no">:highfive:</span></p>' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status content contains tags' do | ||||
|       let(:status) { Fabricate(:status, text: 'Hello #hola') } | ||||
|  | ||||
|       it 'returns formatted content' do | ||||
|         source_texts = service.send(:source_texts) | ||||
|         expect(source_texts[:content]).to include '<p>Hello <a' | ||||
|         expect(source_texts[:content]).to include '/tags/hola' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has spoiler text' do | ||||
|       let(:status) { Fabricate(:status, spoiler_text: 'Hello :highfive:') } | ||||
|  | ||||
|       it 'returns formatted spoiler text' do | ||||
|         source_texts = service.send(:source_texts) | ||||
|         expect(source_texts[:spoiler_text]).to eq 'Hello <span translate="no">:highfive:</span>' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has poll' do | ||||
|       let(:poll) { Fabricate(:poll, options: %w(Blue Green)) } | ||||
|  | ||||
|       it 'returns formatted poll options' do | ||||
|         source_texts = service.send(:source_texts) | ||||
|         expect(source_texts.size).to eq 3 | ||||
|         expect(source_texts.values).to eq %w(<p>Hello</p> Blue Green) | ||||
|  | ||||
|         expect(source_texts.keys.first).to eq :content | ||||
|  | ||||
|         option1 = source_texts.keys.second | ||||
|         expect(option1).to be_a Poll::Option | ||||
|         expect(option1.id).to eq '0' | ||||
|         expect(option1.title).to eq 'Blue' | ||||
|  | ||||
|         option2 = source_texts.keys.third | ||||
|         expect(option2).to be_a Poll::Option | ||||
|         expect(option2.id).to eq '1' | ||||
|         expect(option2.title).to eq 'Green' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has poll with custom emoji' do | ||||
|       let(:poll) { Fabricate(:poll, options: ['Blue', 'Green :highfive:']) } | ||||
|  | ||||
|       it 'returns formatted poll options' do | ||||
|         html = service.send(:source_texts).values.last | ||||
|         expect(html).to eq 'Green <span translate="no">:highfive:</span>' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'status has media attachments' do | ||||
|       let(:text) { '' } | ||||
|       let(:media_attachments) { [Fabricate(:media_attachment, description: 'Hello :highfive:')] } | ||||
|  | ||||
|       it 'returns media attachments without custom emoji rendering' do | ||||
|         source_texts = service.send(:source_texts) | ||||
|         expect(source_texts.size).to eq 1 | ||||
|  | ||||
|         key, text = source_texts.first | ||||
|         expect(key).to eq media_attachments.first | ||||
|         expect(text).to eq 'Hello :highfive:' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#wrap_emoji_shortcodes' do | ||||
|     before do | ||||
|       service.instance_variable_set(:@status, status) | ||||
|     end | ||||
|  | ||||
|     describe 'string contains custom emoji' do | ||||
|       let(:text) { ':highfive:' } | ||||
|  | ||||
|       it 'renders the emoji' do | ||||
|         html = service.send(:wrap_emoji_shortcodes, 'Hello :highfive:'.html_safe) | ||||
|         expect(html).to eq 'Hello <span translate="no">:highfive:</span>' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#unwrap_emoji_shortcodes' do | ||||
|     describe 'string contains custom emoji' do | ||||
|       it 'inserts the shortcode' do | ||||
|         fragment = service.send(:unwrap_emoji_shortcodes, '<p>Hello <span translate="no">:highfive:</span>!</p>') | ||||
|         expect(fragment.to_html).to eq '<p>Hello :highfive:!</p>' | ||||
|       end | ||||
|  | ||||
|       it 'preserves other attributes than translate=no' do | ||||
|         fragment = service.send(:unwrap_emoji_shortcodes, '<p>Hello <span translate="no" class="foo">:highfive:</span>!</p>') | ||||
|         expect(fragment.to_html).to eq '<p>Hello <span class="foo">:highfive:</span>!</p>' | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user