mirror of https://github.com/mastodon/mastodon
Change RTL detection to rely on unicode-bidi paragraph by paragraph (#14573)
This commit is contained in:
parent
1045549f85
commit
1f564051b6
|
@ -92,22 +92,6 @@ module StatusesHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def rtl_status?(status)
|
|
||||||
status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text))
|
|
||||||
end
|
|
||||||
|
|
||||||
def rtl?(text)
|
|
||||||
text = simplified_text(text)
|
|
||||||
rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m)
|
|
||||||
|
|
||||||
if rtl_words.present?
|
|
||||||
total_size = text.size.to_f
|
|
||||||
rtl_size(rtl_words) / total_size > 0.3
|
|
||||||
else
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def fa_visibility_icon(status)
|
def fa_visibility_icon(status)
|
||||||
case status.visibility
|
case status.visibility
|
||||||
when 'public'
|
when 'public'
|
||||||
|
@ -143,10 +127,6 @@ module StatusesHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def rtl_size(words)
|
|
||||||
words.reduce(0) { |acc, elem| acc + elem.size }.to_f
|
|
||||||
end
|
|
||||||
|
|
||||||
def embedded_view?
|
def embedded_view?
|
||||||
params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
|
params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import AutosuggestHashtag from './autosuggest_hashtag';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
@ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
|
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
|
||||||
const { suggestionsHidden } = this.state;
|
const { suggestionsHidden } = this.state;
|
||||||
const style = { direction: 'ltr' };
|
|
||||||
|
|
||||||
if (isRtl(value)) {
|
|
||||||
style.direction = 'rtl';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-input'>
|
<div className='autosuggest-input'>
|
||||||
|
@ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
style={style}
|
dir='auto'
|
||||||
aria-autocomplete='list'
|
aria-autocomplete='list'
|
||||||
id={id}
|
id={id}
|
||||||
className={className}
|
className={className}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import AutosuggestHashtag from './autosuggest_hashtag';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
|
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
|
||||||
const { suggestionsHidden } = this.state;
|
const { suggestionsHidden } = this.state;
|
||||||
const style = { direction: 'ltr' };
|
|
||||||
|
|
||||||
if (isRtl(value)) {
|
|
||||||
style.direction = 'rtl';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
||||||
|
@ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onPaste={this.onPaste}
|
onPaste={this.onPaste}
|
||||||
style={style}
|
dir='auto'
|
||||||
aria-autocomplete='list'
|
aria-autocomplete='list'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
@ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent {
|
||||||
|
|
||||||
const content = { __html: status.get('contentHtml') };
|
const content = { __html: status.get('contentHtml') };
|
||||||
const spoilerContent = { __html: status.get('spoilerHtml') };
|
const spoilerContent = { __html: status.get('spoilerHtml') };
|
||||||
const directionStyle = { direction: 'ltr' };
|
|
||||||
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,
|
||||||
'status__content--collapsed': renderReadMore,
|
'status__content--collapsed': renderReadMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isRtl(status.get('search_index'))) {
|
|
||||||
directionStyle.direction = 'rtl';
|
|
||||||
}
|
|
||||||
|
|
||||||
const showThreadButton = (
|
const showThreadButton = (
|
||||||
<button className='status__content__read-more-button' onClick={this.props.onClick}>
|
<button className='status__content__read-more-button' onClick={this.props.onClick}>
|
||||||
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
|
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
|
||||||
|
@ -225,7 +219,7 @@ export default class StatusContent extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
<span dangerouslySetInnerHTML={spoilerContent} />
|
||||||
{' '}
|
{' '}
|
||||||
|
@ -234,7 +228,7 @@ export default class StatusContent extends React.PureComponent {
|
||||||
|
|
||||||
{mentionsPlaceholder}
|
{mentionsPlaceholder}
|
||||||
|
|
||||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
|
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
|
|
||||||
|
@ -243,8 +237,8 @@ export default class StatusContent extends React.PureComponent {
|
||||||
);
|
);
|
||||||
} else if (this.props.onClick) {
|
} else if (this.props.onClick) {
|
||||||
const output = [
|
const output = [
|
||||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
||||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
|
<div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
|
|
||||||
|
@ -259,8 +253,8 @@ export default class StatusContent extends React.PureComponent {
|
||||||
return output;
|
return output;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
|
<div className={classNames} ref={this.setRef} tabIndex='0'>
|
||||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
|
<div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import IconButton from '../../../components/icon_button';
|
||||||
import DisplayName from '../../../components/display_name';
|
import DisplayName from '../../../components/display_name';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { isRtl } from '../../../rtl';
|
|
||||||
import AttachmentList from 'mastodon/components/attachment_list';
|
import AttachmentList from 'mastodon/components/attachment_list';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = { __html: status.get('contentHtml') };
|
const content = { __html: status.get('contentHtml') };
|
||||||
const style = {
|
|
||||||
direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='reply-indicator'>
|
<div className='reply-indicator'>
|
||||||
|
@ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
|
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{status.get('media_attachments').size > 0 && (
|
{status.get('media_attachments').size > 0 && (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
// U+0590 to U+05FF - Hebrew
|
|
||||||
// U+0600 to U+06FF - Arabic
|
|
||||||
// U+0700 to U+074F - Syriac
|
|
||||||
// U+0750 to U+077F - Arabic Supplement
|
|
||||||
// U+0780 to U+07BF - Thaana
|
|
||||||
// U+07C0 to U+07FF - N'Ko
|
|
||||||
// U+0800 to U+083F - Samaritan
|
|
||||||
// U+08A0 to U+08FF - Arabic Extended-A
|
|
||||||
// U+FB1D to U+FB4F - Hebrew presentation forms
|
|
||||||
// U+FB50 to U+FDFF - Arabic presentation forms A
|
|
||||||
// U+FE70 to U+FEFF - Arabic presentation forms B
|
|
||||||
|
|
||||||
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
|
|
||||||
|
|
||||||
export function isRtl(text) {
|
|
||||||
if (text.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, '');
|
|
||||||
text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, '');
|
|
||||||
text = text.replace(/\s+/g, '');
|
|
||||||
text = text.replace(/(\w\S+\.\w{2,}\S*)/g, '');
|
|
||||||
|
|
||||||
const matches = text.match(rtlChars);
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches.length / text.length > 0.3;
|
|
||||||
};
|
|
|
@ -58,6 +58,16 @@ td {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-dir {
|
||||||
|
p {
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.email-table,
|
.email-table,
|
||||||
.content-section,
|
.content-section,
|
||||||
.column,
|
.column,
|
||||||
|
@ -96,7 +106,7 @@ body {
|
||||||
.col-3,
|
.col-3,
|
||||||
.col-4,
|
.col-4,
|
||||||
.col-5,
|
.col-5,
|
||||||
.col-6, {
|
.col-6 {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -831,6 +831,7 @@
|
||||||
p {
|
p {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
@ -840,6 +841,7 @@
|
||||||
a {
|
a {
|
||||||
color: $secondary-text-color;
|
color: $secondary-text-color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
|
@ -26,11 +26,11 @@
|
||||||
= "@#{status.account.acct}"
|
= "@#{status.account.acct}"
|
||||||
|
|
||||||
- if status.spoiler_text?
|
- if status.spoiler_text?
|
||||||
%div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
%div.auto-dir
|
||||||
%p
|
%p
|
||||||
= Formatter.instance.format_spoiler(status)
|
= Formatter.instance.format_spoiler(status)
|
||||||
|
|
||||||
%div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
%div.auto-dir
|
||||||
= Formatter.instance.format(status)
|
= Formatter.instance.format(status)
|
||||||
|
|
||||||
- if status.media_attachments.size > 0
|
- if status.media_attachments.size > 0
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
%p<
|
%p<
|
||||||
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
||||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||||
.e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
.e-content
|
||||||
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
||||||
- if status.preloadable_poll
|
- if status.preloadable_poll
|
||||||
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
%p<
|
%p<
|
||||||
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
||||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||||
.e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
.e-content
|
||||||
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
||||||
- if status.preloadable_poll
|
- if status.preloadable_poll
|
||||||
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
||||||
|
|
|
@ -149,22 +149,4 @@ RSpec.describe StatusesHelper, type: :helper do
|
||||||
expect(css_class).to eq 'h-cite'
|
expect(css_class).to eq 'h-cite'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#rtl?' do
|
|
||||||
it 'is false if text is empty' do
|
|
||||||
expect(helper).not_to be_rtl ''
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'is false if there are no right to left characters' do
|
|
||||||
expect(helper).not_to be_rtl 'hello world'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'is false if right to left characters are fewer than 1/3 of total text' do
|
|
||||||
expect(helper).not_to be_rtl 'hello ݟ world'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'is true if right to left characters are greater than 1/3 of total text' do
|
|
||||||
expect(helper).to be_rtl 'aaݟaaݟ'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue