Merge commit 'b922ad7a1b4bc8d968d0df2c7f307d4fec53435d' into glitch-soc/merge-upstream
Conflicts: - `package.json`: Upstream changed various script definitions in lines surrounding the one for `i18n:extract`, which had glitch-soc-specific changes. Updated the scripts as upstream did, while keeping our changes to `i18n:extract`.pull/62/head
commit
93c714417f
|
@ -48,4 +48,4 @@ jobs:
|
||||||
- run: echo "::add-matcher::.github/stylelint-matcher.json"
|
- run: echo "::add-matcher::.github/stylelint-matcher.json"
|
||||||
|
|
||||||
- name: Stylelint
|
- name: Stylelint
|
||||||
run: yarn test:lint:sass
|
run: yarn lint:sass
|
||||||
|
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
run: yarn --frozen-lockfile
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: ESLint
|
- name: ESLint
|
||||||
run: yarn test:lint:js --max-warnings 0
|
run: yarn lint:js --max-warnings 0
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: yarn test:typecheck
|
run: yarn typecheck
|
||||||
|
|
|
@ -40,4 +40,4 @@ jobs:
|
||||||
run: yarn --frozen-lockfile
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: Prettier
|
- name: Prettier
|
||||||
run: yarn prettier --check "**/*.json"
|
run: yarn lint:json
|
||||||
|
|
|
@ -5,6 +5,7 @@ on:
|
||||||
- 'dependabot/**'
|
- 'dependabot/**'
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/lint-md.yml'
|
- '.github/workflows/lint-md.yml'
|
||||||
|
- '.nvmrc'
|
||||||
- '.prettier*'
|
- '.prettier*'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '!AUTHORS.md'
|
- '!AUTHORS.md'
|
||||||
|
@ -14,6 +15,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/lint-md.yml'
|
- '.github/workflows/lint-md.yml'
|
||||||
|
- '.nvmrc'
|
||||||
- '.prettier*'
|
- '.prettier*'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- '!AUTHORS.md'
|
- '!AUTHORS.md'
|
||||||
|
@ -32,9 +34,10 @@ jobs:
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
cache: yarn
|
cache: yarn
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Install all yarn packages
|
- name: Install all yarn packages
|
||||||
run: yarn --frozen-lockfile
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: Prettier
|
- name: Prettier
|
||||||
run: yarn prettier --check "**/*.md"
|
run: yarn lint:md
|
||||||
|
|
|
@ -42,4 +42,4 @@ jobs:
|
||||||
run: yarn --frozen-lockfile
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: Prettier
|
- name: Prettier
|
||||||
run: yarn prettier --check "**/*.{yml,yaml}"
|
run: yarn lint:yml
|
||||||
|
|
|
@ -44,4 +44,4 @@ jobs:
|
||||||
run: yarn --frozen-lockfile
|
run: yarn --frozen-lockfile
|
||||||
|
|
||||||
- name: Jest testing
|
- name: Jest testing
|
||||||
run: yarn test:jest --reporters github-actions summary
|
run: yarn jest --reporters github-actions summary
|
||||||
|
|
|
@ -11,7 +11,7 @@ class Api::V1::ConversationsController < Api::BaseController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@conversations = paginated_conversations
|
@conversations = paginated_conversations
|
||||||
render json: @conversations, each_serializer: REST::ConversationSerializer
|
render json: @conversations, each_serializer: REST::ConversationSerializer, relationships: StatusRelationshipsPresenter.new(@conversations.map(&:last_status), current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def read
|
def read
|
||||||
|
@ -32,7 +32,20 @@ class Api::V1::ConversationsController < Api::BaseController
|
||||||
|
|
||||||
def paginated_conversations
|
def paginated_conversations
|
||||||
AccountConversation.where(account: current_account)
|
AccountConversation.where(account: current_account)
|
||||||
.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
.includes(
|
||||||
|
account: :account_stat,
|
||||||
|
last_status: [
|
||||||
|
:media_attachments,
|
||||||
|
:preview_cards,
|
||||||
|
:status_stat,
|
||||||
|
:tags,
|
||||||
|
{
|
||||||
|
active_mentions: [account: :account_stat],
|
||||||
|
account: :account_stat,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.to_a_paginated_by_id(limit_param(LIMIT), **params_slice(:max_id, :since_id, :min_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_pagination_headers
|
def insert_pagination_headers
|
||||||
|
|
|
@ -170,11 +170,11 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def storage_host
|
def storage_host
|
||||||
URI::HTTPS.build(host: storage_host_name).to_s
|
"https://#{storage_host_var}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def storage_host?
|
def storage_host?
|
||||||
storage_host_name.present?
|
storage_host_var.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote_wrap(text, line_width: 80, break_sequence: "\n")
|
def quote_wrap(text, line_width: 80, break_sequence: "\n")
|
||||||
|
@ -235,7 +235,7 @@ module ApplicationHelper
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def storage_host_name
|
def storage_host_var
|
||||||
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
|
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { unescapeHTML } from '../../utils/html';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
|
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
|
||||||
obj[`:${emoji.shortcode}:`] = emoji;
|
obj[`:${emoji.shortcode}:`] = emoji;
|
||||||
return obj;
|
return obj;
|
||||||
}, {});
|
}, {});
|
||||||
|
@ -20,7 +20,7 @@ export function searchTextFromRawStatus (status) {
|
||||||
export function normalizeAccount(account) {
|
export function normalizeAccount(account) {
|
||||||
account = { ...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;
|
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
|
||||||
|
|
||||||
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
|
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
|
||||||
|
@ -86,7 +86,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
|
|
||||||
const spoilerText = normalStatus.spoiler_text || '';
|
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 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.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||||
|
@ -97,22 +97,48 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
return normalStatus;
|
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) {
|
export function normalizePoll(poll) {
|
||||||
const normalPoll = { ...poll };
|
const normalPoll = { ...poll };
|
||||||
const emojiMap = makeEmojiMap(normalPoll);
|
const emojiMap = makeEmojiMap(poll.emojis);
|
||||||
|
|
||||||
normalPoll.options = poll.options.map((option, index) => ({
|
normalPoll.options = poll.options.map((option, index) => ({
|
||||||
...option,
|
...option,
|
||||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
voted: poll.own_votes && poll.own_votes.includes(index),
|
||||||
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return normalPoll;
|
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) {
|
export function normalizeAnnouncement(announcement) {
|
||||||
const normalAnnouncement = { ...announcement };
|
const normalAnnouncement = { ...announcement };
|
||||||
const emojiMap = makeEmojiMap(normalAnnouncement);
|
const emojiMap = makeEmojiMap.emojis(normalAnnouncement);
|
||||||
|
|
||||||
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
|
||||||
|
|
||||||
|
|
|
@ -343,7 +343,8 @@ export const translateStatusFail = (id, error) => ({
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const undoStatusTranslation = id => ({
|
export const undoStatusTranslation = (id, pollId) => ({
|
||||||
type: STATUS_TRANSLATE_UNDO,
|
type: STATUS_TRANSLATE_UNDO,
|
||||||
id,
|
id,
|
||||||
|
pollId,
|
||||||
});
|
});
|
||||||
|
|
|
@ -51,8 +51,9 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, lang, width, height } = this.props;
|
const { status, width, height } = this.props;
|
||||||
const mediaAttachments = status.get('media_attachments');
|
const mediaAttachments = status.get('media_attachments');
|
||||||
|
const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang;
|
||||||
|
|
||||||
if (mediaAttachments.size === 0) {
|
if (mediaAttachments.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -60,14 +61,15 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (mediaAttachments.getIn([0, 'type']) === 'audio') {
|
if (mediaAttachments.getIn([0, 'type']) === 'audio') {
|
||||||
const audio = mediaAttachments.get(0);
|
const audio = mediaAttachments.get(0);
|
||||||
|
const description = audio.getIn(['translation', 'description']) || audio.get('description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
src={audio.get('url')}
|
src={audio.get('url')}
|
||||||
alt={audio.get('description')}
|
alt={description}
|
||||||
lang={lang || status.get('language')}
|
lang={language}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
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') {
|
} else if (mediaAttachments.getIn([0, 'type']) === 'video') {
|
||||||
const video = mediaAttachments.get(0);
|
const video = mediaAttachments.get(0);
|
||||||
|
const description = video.getIn(['translation', 'description']) || video.get('description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||||
|
@ -90,8 +93,8 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
||||||
frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
blurhash={video.get('blurhash')}
|
blurhash={video.get('blurhash')}
|
||||||
src={video.get('url')}
|
src={video.get('url')}
|
||||||
alt={video.get('description')}
|
alt={description}
|
||||||
lang={lang || status.get('language')}
|
lang={language}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
inline
|
inline
|
||||||
|
@ -107,7 +110,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
media={mediaAttachments}
|
media={mediaAttachments}
|
||||||
lang={lang || status.get('language')}
|
lang={language}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
defaultWidth={width}
|
defaultWidth={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
|
|
@ -105,10 +105,12 @@ class Item extends PureComponent {
|
||||||
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
|
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') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
<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
|
<Blurhash
|
||||||
hash={attachment.get('blurhash')}
|
hash={attachment.get('blurhash')}
|
||||||
className='media-gallery__preview'
|
className='media-gallery__preview'
|
||||||
|
@ -146,8 +148,8 @@ class Item extends PureComponent {
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
srcSet={srcSet}
|
srcSet={srcSet}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
alt={attachment.get('description')}
|
alt={description}
|
||||||
title={attachment.get('description')}
|
title={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
style={{ objectPosition: `${x}% ${y}%` }}
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
onLoad={this.handleImageLoad}
|
onLoad={this.handleImageLoad}
|
||||||
|
@ -163,8 +165,8 @@ class Item extends PureComponent {
|
||||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||||
<video
|
<video
|
||||||
className='media-gallery__item-gifv-thumbnail'
|
className='media-gallery__item-gifv-thumbnail'
|
||||||
aria-label={attachment.get('description')}
|
aria-label={description}
|
||||||
title={attachment.get('description')}
|
title={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
role='application'
|
role='application'
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
|
|
|
@ -138,10 +138,12 @@ class Poll extends ImmutablePureComponent {
|
||||||
const active = !!this.state.selected[`${optionIndex}`];
|
const active = !!this.state.selected[`${optionIndex}`];
|
||||||
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
|
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
|
||||||
|
|
||||||
let titleEmojified = option.get('title_emojified');
|
const title = option.getIn(['translation', 'title']) || option.get('title');
|
||||||
if (!titleEmojified) {
|
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
||||||
|
|
||||||
|
if (!titleHtml) {
|
||||||
const emojiMap = makeEmojiMap(poll);
|
const emojiMap = makeEmojiMap(poll);
|
||||||
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
|
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -163,7 +165,7 @@ class Poll extends ImmutablePureComponent {
|
||||||
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
role={poll.get('multiple') ? 'checkbox' : 'radio'}
|
||||||
onKeyPress={this.handleOptionKeyPress}
|
onKeyPress={this.handleOptionKeyPress}
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
aria-label={option.get('title')}
|
aria-label={title}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
data-index={optionIndex}
|
data-index={optionIndex}
|
||||||
/>
|
/>
|
||||||
|
@ -182,7 +184,7 @@ class Poll extends ImmutablePureComponent {
|
||||||
<span
|
<span
|
||||||
className='poll__option__text translate'
|
className='poll__option__text translate'
|
||||||
lang={lang}
|
lang={lang}
|
||||||
dangerouslySetInnerHTML={{ __html: titleEmojified }}
|
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!!voted && <span className='poll__voted'>
|
{!!voted && <span className='poll__voted'>
|
||||||
|
|
|
@ -27,12 +27,18 @@ import { RelativeTimestamp } from './relative_timestamp';
|
||||||
import StatusActionBar from './status_action_bar';
|
import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
|
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
const displayName = status.getIn(['account', 'display_name']);
|
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 = [
|
const values = [
|
||||||
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
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' }),
|
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||||
status.getIn(['account', 'acct']),
|
status.getIn(['account', 'acct']),
|
||||||
];
|
];
|
||||||
|
@ -199,12 +205,14 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleOpenVideo = (options) => {
|
handleOpenVideo = (options) => {
|
||||||
const status = this._properStatus();
|
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) => {
|
handleOpenMedia = (media, index) => {
|
||||||
const status = this._properStatus();
|
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 => {
|
handleHotkeyOpenMedia = e => {
|
||||||
|
@ -214,7 +222,7 @@ class Status extends ImmutablePureComponent {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (status.get('media_attachments').size > 0) {
|
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') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
|
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
|
||||||
} else {
|
} else {
|
||||||
|
@ -420,6 +428,8 @@ class Status extends ImmutablePureComponent {
|
||||||
if (pictureInPicture.get('inUse')) {
|
if (pictureInPicture.get('inUse')) {
|
||||||
media = <PictureInPicturePlaceholder />;
|
media = <PictureInPicturePlaceholder />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
|
|
||||||
if (this.props.muted) {
|
if (this.props.muted) {
|
||||||
media = (
|
media = (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
|
@ -429,14 +439,15 @@ class Status extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={description}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
@ -456,6 +467,7 @@ class Status extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||||
|
@ -465,8 +477,8 @@ class Status extends ImmutablePureComponent {
|
||||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
blurhash={attachment.get('blurhash')}
|
blurhash={attachment.get('blurhash')}
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={description}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
inline
|
inline
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
onOpenVideo={this.handleOpenVideo}
|
onOpenVideo={this.handleOpenVideo}
|
||||||
|
@ -483,7 +495,7 @@ class Status extends ImmutablePureComponent {
|
||||||
{Component => (
|
{Component => (
|
||||||
<Component
|
<Component
|
||||||
media={status.get('media_attachments')}
|
media={status.get('media_attachments')}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
height={110}
|
height={110}
|
||||||
onOpenMedia={this.handleOpenMedia}
|
onOpenMedia={this.handleOpenMedia}
|
||||||
|
|
|
@ -231,11 +231,11 @@ class StatusContent extends PureComponent {
|
||||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||||
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
||||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
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 content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') };
|
||||||
const spoilerContent = { __html: status.get('spoilerHtml') };
|
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||||
const lang = status.get('translation') ? intl.locale : status.get('language');
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
'status__content--with-action': this.props.onClick && this.context.router,
|
'status__content--with-action': this.props.onClick && this.context.router,
|
||||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
||||||
|
@ -253,7 +253,7 @@ class StatusContent extends PureComponent {
|
||||||
);
|
);
|
||||||
|
|
||||||
const poll = !!status.get('poll') && (
|
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) {
|
if (status.get('spoiler_text').length > 0) {
|
||||||
|
@ -274,24 +274,24 @@ class StatusContent extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<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 }}>
|
<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>
|
<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>
|
</p>
|
||||||
|
|
||||||
{mentionsPlaceholder}
|
{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 && poll}
|
||||||
{!hidden && translateButton}
|
{translateButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (this.props.onClick) {
|
} else if (this.props.onClick) {
|
||||||
return (
|
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={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}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
@ -303,7 +303,7 @@ class StatusContent extends PureComponent {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<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}
|
{poll}
|
||||||
{translateButton}
|
{translateButton}
|
||||||
|
|
|
@ -180,7 +180,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
||||||
|
|
||||||
onTranslate (status) {
|
onTranslate (status) {
|
||||||
if (status.get('translation')) {
|
if (status.get('translation')) {
|
||||||
dispatch(undoStatusTranslation(status.get('id')));
|
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(translateStatus(status.get('id')));
|
dispatch(translateStatus(status.get('id')));
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,17 +133,20 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
outerStyle.height = `${this.state.height}px`;
|
outerStyle.height = `${this.state.height}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
|
|
||||||
if (pictureInPicture.get('inUse')) {
|
if (pictureInPicture.get('inUse')) {
|
||||||
media = <PictureInPicturePlaceholder />;
|
media = <PictureInPicturePlaceholder />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Audio
|
<Audio
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={description}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
@ -158,6 +161,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
const attachment = status.getIn(['media_attachments', 0]);
|
const attachment = status.getIn(['media_attachments', 0]);
|
||||||
|
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||||
|
|
||||||
media = (
|
media = (
|
||||||
<Video
|
<Video
|
||||||
|
@ -165,8 +169,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
blurhash={attachment.get('blurhash')}
|
blurhash={attachment.get('blurhash')}
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={description}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
width={300}
|
width={300}
|
||||||
height={150}
|
height={150}
|
||||||
inline
|
inline
|
||||||
|
@ -182,7 +186,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
standalone
|
standalone
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
media={status.get('media_attachments')}
|
media={status.get('media_attachments')}
|
||||||
lang={status.get('language')}
|
lang={language}
|
||||||
height={300}
|
height={300}
|
||||||
onOpenMedia={this.props.onOpenMedia}
|
onOpenMedia={this.props.onOpenMedia}
|
||||||
visible={this.props.showMedia}
|
visible={this.props.showMedia}
|
||||||
|
|
|
@ -430,7 +430,7 @@ class Status extends ImmutablePureComponent {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
if (status.get('translation')) {
|
if (status.get('translation')) {
|
||||||
dispatch(undoStatusTranslation(status.get('id')));
|
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(translateStatus(status.get('id')));
|
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';
|
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
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']),
|
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ class AudioModal extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
statusId: PropTypes.string.isRequired,
|
statusId: PropTypes.string.isRequired,
|
||||||
language: PropTypes.string,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
accountStaticAvatar: PropTypes.string.isRequired,
|
accountStaticAvatar: PropTypes.string.isRequired,
|
||||||
options: PropTypes.shape({
|
options: PropTypes.shape({
|
||||||
autoPlay: PropTypes.bool,
|
autoPlay: PropTypes.bool,
|
||||||
|
@ -27,15 +27,17 @@ class AudioModal extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, language, accountStaticAvatar, statusId, onClose } = this.props;
|
const { media, status, accountStaticAvatar, onClose } = this.props;
|
||||||
const options = this.props.options || {};
|
const options = this.props.options || {};
|
||||||
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
|
const description = media.getIn(['translation', 'description']) || media.get('description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal audio-modal'>
|
<div className='modal-root__modal audio-modal'>
|
||||||
<div className='audio-modal__container'>
|
<div className='audio-modal__container'>
|
||||||
<Audio
|
<Audio
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
alt={media.get('description')}
|
alt={description}
|
||||||
lang={language}
|
lang={language}
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
height={150}
|
height={150}
|
||||||
|
@ -48,7 +50,7 @@ class AudioModal extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='media-modal__overlay'>
|
<div className='media-modal__overlay'>
|
||||||
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
|
{status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -145,6 +145,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
const content = media.map((image) => {
|
const content = media.map((image) => {
|
||||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||||
|
const description = image.getIn(['translation', 'description']) || image.get('description');
|
||||||
|
|
||||||
if (image.get('type') === 'image') {
|
if (image.get('type') === 'image') {
|
||||||
return (
|
return (
|
||||||
|
@ -153,7 +154,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
alt={image.get('description')}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
onClick={this.toggleNavigation}
|
onClick={this.toggleNavigation}
|
||||||
|
@ -176,7 +177,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
volume={volume || 1}
|
volume={volume || 1}
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
detailed
|
detailed
|
||||||
alt={image.get('description')}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
/>
|
/>
|
||||||
|
@ -188,7 +189,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
alt={image.get('description')}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={this.toggleNavigation}
|
onClick={this.toggleNavigation}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -9,7 +9,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
import Video from 'mastodon/features/video';
|
import Video from 'mastodon/features/video';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
const mapStateToProps = (state, { statusId }) => ({
|
||||||
language: state.getIn(['statuses', statusId, 'language']),
|
status: state.getIn(['statuses', statusId]),
|
||||||
});
|
});
|
||||||
|
|
||||||
class VideoModal extends ImmutablePureComponent {
|
class VideoModal extends ImmutablePureComponent {
|
||||||
|
@ -17,7 +17,7 @@ class VideoModal extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
statusId: PropTypes.string,
|
statusId: PropTypes.string,
|
||||||
language: PropTypes.string,
|
status: ImmutablePropTypes.map,
|
||||||
options: PropTypes.shape({
|
options: PropTypes.shape({
|
||||||
startTime: PropTypes.number,
|
startTime: PropTypes.number,
|
||||||
autoPlay: PropTypes.bool,
|
autoPlay: PropTypes.bool,
|
||||||
|
@ -38,8 +38,10 @@ class VideoModal extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, statusId, language, onClose } = this.props;
|
const { media, status, onClose } = this.props;
|
||||||
const options = this.props.options || {};
|
const options = this.props.options || {};
|
||||||
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
|
const description = media.getIn(['translation', 'description']) || media.get('description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal video-modal'>
|
<div className='modal-root__modal video-modal'>
|
||||||
|
@ -55,13 +57,13 @@ class VideoModal extends ImmutablePureComponent {
|
||||||
onCloseVideo={onClose}
|
onCloseVideo={onClose}
|
||||||
autoFocus
|
autoFocus
|
||||||
detailed
|
detailed
|
||||||
alt={media.get('description')}
|
alt={description}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='media-modal__overlay'>
|
<div className='media-modal__overlay'>
|
||||||
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />}
|
{status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,14 +2,43 @@ import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
import { POLLS_IMPORT } from 'mastodon/actions/importer';
|
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 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();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
export default function polls(state = initialState, action) {
|
export default function polls(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case POLLS_IMPORT:
|
case POLLS_IMPORT:
|
||||||
return importPolls(state, action.polls);
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||||
|
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
||||||
import {
|
import {
|
||||||
REBLOG_REQUEST,
|
REBLOG_REQUEST,
|
||||||
REBLOG_FAIL,
|
REBLOG_FAIL,
|
||||||
|
@ -36,6 +37,27 @@ const deleteStatus = (state, id, references) => {
|
||||||
return state.delete(id);
|
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();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
export default function statuses(state = initialState, action) {
|
export default function statuses(state = initialState, action) {
|
||||||
|
@ -87,9 +109,9 @@ export default function statuses(state = initialState, action) {
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return deleteStatus(state, action.id, action.references);
|
return deleteStatus(state, action.id, action.references);
|
||||||
case STATUS_TRANSLATE_SUCCESS:
|
case STATUS_TRANSLATE_SUCCESS:
|
||||||
return state.setIn([action.id, 'translation'], fromJS(action.translation));
|
return statusTranslateSuccess(state, action.id, action.translation);
|
||||||
case STATUS_TRANSLATE_UNDO:
|
case STATUS_TRANSLATE_UNDO:
|
||||||
return state.deleteIn([action.id, 'translation']);
|
return statusTranslateUndo(state, action.id);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5470,7 +5470,7 @@ a.status-card.compact:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: rgba($primary-text-color, 0.7);
|
color: rgba($white, 0.7);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -5485,7 +5485,7 @@ a.status-card.compact:hover {
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&:active {
|
&:active {
|
||||||
color: $primary-text-color;
|
color: $white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ class EmojiFormatter
|
||||||
# @param [Hash] options
|
# @param [Hash] options
|
||||||
# @option options [Boolean] :animate
|
# @option options [Boolean] :animate
|
||||||
# @option options [String] :style
|
# @option options [String] :style
|
||||||
|
# @option options [String] :raw_shortcode
|
||||||
def initialize(html, custom_emojis, options = {})
|
def initialize(html, custom_emojis, options = {})
|
||||||
raise ArgumentError unless html.html_safe?
|
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])
|
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::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
|
last_index = i + 1
|
||||||
elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1]))
|
elsif text[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(text[i - 1]))
|
||||||
|
@ -75,7 +76,9 @@ class EmojiFormatter
|
||||||
end
|
end
|
||||||
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
|
original_url, static_url = emoji
|
||||||
|
|
||||||
image_tag(
|
image_tag(
|
||||||
|
@ -103,4 +106,8 @@ class EmojiFormatter
|
||||||
def animate?
|
def animate?
|
||||||
@options[:animate] || @options.key?(:style)
|
@options[:animate] || @options.key?(:style)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def raw_shortcode?
|
||||||
|
@options[:raw_shortcode]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,8 +10,8 @@ class TranslationService::DeepL < TranslationService
|
||||||
@api_key = api_key
|
@api_key = api_key
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate(text, source_language, target_language)
|
def translate(texts, source_language, target_language)
|
||||||
form = { text: text, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
|
form = { text: texts, source_lang: source_language&.upcase, target_lang: target_language, tag_handling: 'html' }
|
||||||
request(:post, '/v2/translate', form: form) do |res|
|
request(:post, '/v2/translate', form: form) do |res|
|
||||||
transform_response(res.body_with_limit)
|
transform_response(res.body_with_limit)
|
||||||
end
|
end
|
||||||
|
@ -67,12 +67,17 @@ class TranslationService::DeepL < TranslationService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def transform_response(str)
|
def transform_response(json)
|
||||||
json = Oj.load(str, mode: :strict)
|
data = Oj.load(json, mode: :strict)
|
||||||
|
raise UnexpectedResponseError unless data.is_a?(Hash)
|
||||||
|
|
||||||
raise UnexpectedResponseError unless json.is_a?(Hash)
|
data['translations'].map do |translation|
|
||||||
|
Translation.new(
|
||||||
Translation.new(text: json.dig('translations', 0, 'text'), detected_source_language: json.dig('translations', 0, 'detected_source_language')&.downcase, provider: 'DeepL.com')
|
text: translation['text'],
|
||||||
|
detected_source_language: translation['detected_source_language']&.downcase,
|
||||||
|
provider: 'DeepL.com'
|
||||||
|
)
|
||||||
|
end
|
||||||
rescue Oj::ParseError
|
rescue Oj::ParseError
|
||||||
raise UnexpectedResponseError
|
raise UnexpectedResponseError
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,8 +8,8 @@ class TranslationService::LibreTranslate < TranslationService
|
||||||
@api_key = api_key
|
@api_key = api_key
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate(text, source_language, target_language)
|
def translate(texts, source_language, target_language)
|
||||||
body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
|
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|
|
request(:post, '/translate', body: body) do |res|
|
||||||
transform_response(res.body_with_limit, source_language)
|
transform_response(res.body_with_limit, source_language)
|
||||||
end
|
end
|
||||||
|
@ -44,12 +44,17 @@ class TranslationService::LibreTranslate < TranslationService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def transform_response(str, source_language)
|
def transform_response(json, source_language)
|
||||||
json = Oj.load(str, mode: :strict)
|
data = Oj.load(json, mode: :strict)
|
||||||
|
raise UnexpectedResponseError unless data.is_a?(Hash)
|
||||||
|
|
||||||
raise UnexpectedResponseError unless json.is_a?(Hash)
|
data['translatedText'].map.with_index do |text, index|
|
||||||
|
Translation.new(
|
||||||
Translation.new(text: json['translatedText'], detected_source_language: source_language, provider: 'LibreTranslate')
|
text: text,
|
||||||
|
detected_source_language: data.dig('detectedLanguage', index, 'language') || source_language,
|
||||||
|
provider: 'LibreTranslate'
|
||||||
|
)
|
||||||
|
end
|
||||||
rescue Oj::ParseError
|
rescue Oj::ParseError
|
||||||
raise UnexpectedResponseError
|
raise UnexpectedResponseError
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
class AccountConversation < ApplicationRecord
|
class AccountConversation < ApplicationRecord
|
||||||
include Redisable
|
include Redisable
|
||||||
|
|
||||||
|
attr_writer :participant_accounts
|
||||||
|
|
||||||
before_validation :set_last_status
|
before_validation :set_last_status
|
||||||
after_commit :push_to_streaming_api
|
after_commit :push_to_streaming_api
|
||||||
|
|
||||||
|
@ -26,26 +28,42 @@ class AccountConversation < ApplicationRecord
|
||||||
|
|
||||||
def participant_account_ids=(arr)
|
def participant_account_ids=(arr)
|
||||||
self[:participant_account_ids] = arr.sort
|
self[:participant_account_ids] = arr.sort
|
||||||
|
@participant_accounts = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def participant_accounts
|
def participant_accounts
|
||||||
|
@participant_accounts ||= begin
|
||||||
if participant_account_ids.empty?
|
if participant_account_ids.empty?
|
||||||
[account]
|
[account]
|
||||||
else
|
else
|
||||||
participants = Account.where(id: participant_account_ids)
|
participants = Account.where(id: participant_account_ids).to_a
|
||||||
participants.empty? ? [account] : participants
|
participants.empty? ? [account] : participants
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def to_a_paginated_by_id(limit, options = {})
|
def to_a_paginated_by_id(limit, min_id: nil, max_id: nil, since_id: nil, preload_participants: true)
|
||||||
if options[:min_id]
|
array = begin
|
||||||
paginate_by_min_id(limit, options[:min_id], options[:max_id]).reverse
|
if min_id
|
||||||
|
paginate_by_min_id(limit, min_id, max_id).reverse
|
||||||
else
|
else
|
||||||
paginate_by_max_id(limit, options[:max_id], options[:since_id]).to_a
|
paginate_by_max_id(limit, max_id, since_id).to_a
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if preload_participants
|
||||||
|
participant_ids = array.flat_map(&:participant_account_ids)
|
||||||
|
accounts_by_id = Account.where(id: participant_ids).index_by(&:id)
|
||||||
|
|
||||||
|
array.each do |conversation|
|
||||||
|
conversation.participant_accounts = conversation.participant_account_ids.filter_map { |id| accounts_by_id[id] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
array
|
||||||
|
end
|
||||||
|
|
||||||
def paginate_by_min_id(limit, min_id = nil, max_id = nil)
|
def paginate_by_min_id(limit, min_id = nil, max_id = nil)
|
||||||
query = order(arel_table[:last_status_id].asc).limit(limit)
|
query = order(arel_table[:last_status_id].asc).limit(limit)
|
||||||
query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
|
query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
|
||||||
|
|
|
@ -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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class REST::TranslationSerializer < ActiveModel::Serializer
|
class REST::TranslationSerializer < ActiveModel::Serializer
|
||||||
attributes :content, :detected_source_language, :provider
|
attributes :detected_source_language, :language, :provider, :spoiler_text, :content
|
||||||
|
|
||||||
def content
|
class PollSerializer < ActiveModel::Serializer
|
||||||
object.text
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,16 +3,24 @@
|
||||||
class TranslateStatusService < BaseService
|
class TranslateStatusService < BaseService
|
||||||
CACHE_TTL = 1.day.freeze
|
CACHE_TTL = 1.day.freeze
|
||||||
|
|
||||||
|
include ERB::Util
|
||||||
include FormattingHelper
|
include FormattingHelper
|
||||||
|
|
||||||
def call(status, target_language)
|
def call(status, target_language)
|
||||||
@status = status
|
@status = status
|
||||||
@content = status_content_format(@status)
|
@source_texts = source_texts
|
||||||
@target_language = target_language
|
@target_language = target_language
|
||||||
|
|
||||||
raise Mastodon::NotPermittedError unless permitted?
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -22,7 +30,7 @@ class TranslateStatusService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted?
|
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)
|
languages[@status.language]&.include?(@target_language)
|
||||||
end
|
end
|
||||||
|
@ -32,6 +40,73 @@ class TranslateStatusService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def content_hash
|
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
|
||||||
end
|
end
|
||||||
|
|
29
package.json
29
package.json
|
@ -5,21 +5,28 @@
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postversion": "git push --tags",
|
|
||||||
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/webpack",
|
"build:development": "cross-env RAILS_ENV=development NODE_ENV=development ./bin/webpack",
|
||||||
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack",
|
"build:production": "cross-env RAILS_ENV=production NODE_ENV=production ./bin/webpack",
|
||||||
"manage:translations": "node ./config/webpack/translationRunner.js",
|
"fix:js": "yarn lint:js --fix",
|
||||||
|
"fix:json": "prettier --write \"**/*.json\"",
|
||||||
|
"fix:md": "prettier --write \"**/*.md\"",
|
||||||
|
"fix:sass": "stylelint --fix \"**/*.{css,scss}\" && prettier --write \"**/*.{css,scss}\"",
|
||||||
|
"fix:yml": "prettier --write \"**/*.{yaml,yml}\"",
|
||||||
|
"fix": "yarn fix:js && yarn fix:json && yarn fix:sass && yarn fix:yml",
|
||||||
"i18n:extract": "formatjs extract 'app/javascript/**/*.{js,jsx,ts,tsx}' '--ignore=**/*.d.ts' --out-file app/javascript/flavours/glitch/locales/en.json --format config/formatjs-formatter.js",
|
"i18n:extract": "formatjs extract 'app/javascript/**/*.{js,jsx,ts,tsx}' '--ignore=**/*.d.ts' --out-file app/javascript/flavours/glitch/locales/en.json --format config/formatjs-formatter.js",
|
||||||
|
"jest": "cross-env NODE_ENV=test jest",
|
||||||
|
"lint:js": "eslint . --ext=.js,.jsx,.ts,.tsx --cache --report-unused-disable-directives",
|
||||||
|
"lint:json": "prettier --check \"**/*.json\"",
|
||||||
|
"lint:md": "prettier --check \"**/*.md\"",
|
||||||
|
"lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
|
||||||
|
"lint:yml": "prettier --check \"**/*.{yaml,yml}\"",
|
||||||
|
"lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml",
|
||||||
|
"manage:translations": "node ./config/webpack/translationRunner.js",
|
||||||
|
"postversion": "git push --tags",
|
||||||
|
"prepare": "husky install",
|
||||||
"start": "node ./streaming/index.js",
|
"start": "node ./streaming/index.js",
|
||||||
"test": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:typecheck && ${npm_execpath} run test:jest",
|
"test": "yarn lint && yarn run typecheck && yarn jest",
|
||||||
"test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass",
|
"typecheck": "tsc --noEmit"
|
||||||
"test:lint:js": "eslint --ext=.js,.jsx,.ts,.tsx . --cache --report-unused-disable-directives",
|
|
||||||
"test:lint:sass": "stylelint \"**/*.{css,scss}\" && prettier --check \"**/*.{css,scss}\"",
|
|
||||||
"test:typecheck": "tsc --noEmit",
|
|
||||||
"test:jest": "cross-env NODE_ENV=test jest",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"format-check": "prettier --check .",
|
|
||||||
"prepare": "husky install"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -19,7 +19,7 @@ describe Api::V1::Statuses::TranslationsController do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
translation = TranslationService::Translation.new(text: 'Hello')
|
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(true)
|
||||||
allow(TranslationService).to receive(:configured).and_return(service)
|
allow(TranslationService).to receive(:configured).and_return(service)
|
||||||
Rails.cache.write('translation_service/languages', { 'es' => ['en'] })
|
Rails.cache.write('translation_service/languages', { 'es' => ['en'] })
|
||||||
|
|
|
@ -209,6 +209,18 @@ describe ApplicationHelper do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when S3 alias includes a path component' do
|
||||||
|
around do |example|
|
||||||
|
ClimateControl.modify S3_ALIAS_HOST: 's3.alias/path' do
|
||||||
|
example.run
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns a correct URL' do
|
||||||
|
expect(helper.storage_host).to eq('https://s3.alias/path')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when S3 cloudfront is present' do
|
context 'when S3 cloudfront is present' do
|
||||||
around do |example|
|
around do |example|
|
||||||
ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do
|
ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do
|
||||||
|
@ -220,12 +232,6 @@ describe ApplicationHelper do
|
||||||
expect(helper.storage_host).to eq('https://s3.cloudfront')
|
expect(helper.storage_host).to eq('https://s3.cloudfront')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when neither env value is present' do
|
|
||||||
it 'returns false' do
|
|
||||||
expect(helper.storage_host).to eq('https:')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'storage_host?' do
|
describe 'storage_host?' do
|
||||||
|
|
|
@ -22,7 +22,10 @@ RSpec.describe TranslationService::DeepL do
|
||||||
.with(body: 'text=Hasta+la+vista&source_lang=ES&target_lang=en&tag_handling=html')
|
.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"}]}')
|
.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.detected_source_language).to eq 'es'
|
||||||
expect(translation.provider).to eq 'DeepL.com'
|
expect(translation.provider).to eq 'DeepL.com'
|
||||||
expect(translation.text).to eq 'See you soon'
|
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
|
it 'returns translation with auto-detected source language' do
|
||||||
stub_request(:post, 'https://api.deepl.com/v2/translate')
|
stub_request(:post, 'https://api.deepl.com/v2/translate')
|
||||||
.with(body: 'text=Guten+Tag&source_lang&target_lang=en&tag_handling=html')
|
.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.detected_source_language).to eq 'de'
|
||||||
expect(translation.provider).to eq 'DeepL.com'
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -31,24 +31,42 @@ RSpec.describe TranslationService::LibreTranslate do
|
||||||
describe '#translate' do
|
describe '#translate' do
|
||||||
it 'returns translation with specified source language' do
|
it 'returns translation with specified source language' do
|
||||||
stub_request(:post, 'https://libretranslate.example.com/translate')
|
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"}')
|
.with(body: '{"q":["Hasta la vista"],"source":"es","target":"en","format":"html","api_key":"my-api-key"}')
|
||||||
.to_return(body: '{"translatedText": "See you"}')
|
.to_return(body: '{"translatedText": ["See you"]}')
|
||||||
|
|
||||||
translation = service.translate('Hasta la vista', 'es', 'en')
|
translations = service.translate(['Hasta la vista'], 'es', 'en')
|
||||||
expect(translation.detected_source_language).to eq 'es'
|
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.provider).to eq 'LibreTranslate'
|
||||||
expect(translation.text).to eq 'See you'
|
expect(translation.text).to eq 'See you'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns translation with auto-detected source language' do
|
it 'returns translation with auto-detected source language' do
|
||||||
stub_request(:post, 'https://libretranslate.example.com/translate')
|
stub_request(:post, 'https://libretranslate.example.com/translate')
|
||||||
.with(body: '{"q":"Guten Morgen","source":"auto","target":"en","format":"html","api_key":"my-api-key"}')
|
.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"}')
|
.to_return(body: '{"detectedLanguage": [{"confidence": 92, "language": "de"}], "translatedText": ["Good morning"]}')
|
||||||
|
|
||||||
translation = service.translate('Guten Morgen', nil, 'en')
|
translations = service.translate(['Guten Morgen'], nil, 'en')
|
||||||
expect(translation.detected_source_language).to be_nil
|
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.provider).to eq 'LibreTranslate'
|
||||||
expect(translation.text).to eq 'Good morning'
|
expect(translation.text).to eq 'Good morning'
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue