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
					
				
			
			
				
	
			
			
			
						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