Merge pull request #181 from glitch-soc/upstream-merge-again

Merge upstream, pull in fixes for tootsuite/mastodon#{5409,5417}
This commit is contained in:
David Yip 2017-10-16 15:46:12 -05:00 committed by GitHub
commit 7c44ad6355
79 changed files with 1618 additions and 821 deletions

View File

@ -5,12 +5,14 @@ env:
browser: true browser: true
node: true node: true
es6: true es6: true
jest: true
parser: babel-eslint parser: babel-eslint
plugins: plugins:
- react - react
- jsx-a11y - jsx-a11y
- import
parserOptions: parserOptions:
sourceType: module sourceType: module
@ -21,8 +23,14 @@ parserOptions:
modules: true modules: true
spread: true spread: true
rules: settings:
import/extensions:
- .js
import/ignore:
- node_modules
- \\.(css|scss|json)$
rules:
brace-style: warn brace-style: warn
comma-dangle: comma-dangle:
- error - error
@ -125,3 +133,17 @@ rules:
jsx-a11y/role-supports-aria-props: off jsx-a11y/role-supports-aria-props: off
jsx-a11y/scope: warn jsx-a11y/scope: warn
jsx-a11y/tabindex-no-positive: warn jsx-a11y/tabindex-no-positive: warn
import/extensions:
- error
- always
- js: never
import/newline-after-import: error
import/no-extraneous-dependencies:
- error
- devDependencies:
- "config/webpack/**"
- "app/javascript/mastodon/test_setup.js"
- "app/javascript/**/__tests__/**"
import/no-unresolved: error
import/no-webpack-loader-syntax: error

View File

@ -53,5 +53,5 @@ before_script:
script: script:
- travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec
- npm test - yarn test
- bundle exec i18n-tasks unused - bundle exec i18n-tasks unused

View File

@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
:setting_boost_modal, :setting_boost_modal,
:setting_delete_modal, :setting_delete_modal,
:setting_auto_play_gif, :setting_auto_play_gif,
:setting_reduce_motion,
:setting_system_font_ui, :setting_system_font_ui,
:setting_noindex, :setting_noindex,
:setting_theme, :setting_theme,

View File

@ -48,7 +48,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports // // Mastodon imports //
import emojify from 'mastodon/features/emoji/emoji'; import emojify from '../../../mastodon/features/emoji/emoji';
import IconButton from '../../../mastodon/components/icon_button'; import IconButton from '../../../mastodon/components/icon_button';
import Avatar from '../../../mastodon/components/avatar'; import Avatar from '../../../mastodon/components/avatar';

View File

@ -2,10 +2,10 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
// Mastodon imports // // Mastodon imports //
import { closeModal } from 'mastodon/actions/modal'; import { closeModal } from '../../../mastodon/actions/modal';
// Our imports // // Our imports //
import { changeLocalSetting } from 'glitch/actions/local_settings'; import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import LocalSettings from '.'; import LocalSettings from '.';
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View File

@ -8,7 +8,7 @@ import LocalSettingsPage from './page';
import LocalSettingsNavigation from './navigation'; import LocalSettingsNavigation from './navigation';
// Stylesheet imports // Stylesheet imports
import './style'; import './style.scss';
export default class LocalSettings extends React.PureComponent { export default class LocalSettings extends React.PureComponent {

View File

@ -7,7 +7,7 @@ import { injectIntl, defineMessages } from 'react-intl';
import LocalSettingsNavigationItem from './item'; import LocalSettingsNavigationItem from './item';
// Stylesheet imports // Stylesheet imports
import './style'; import './style.scss';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
// Stylesheet imports // Stylesheet imports
import './style'; import './style.scss';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

View File

@ -8,7 +8,7 @@ import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import LocalSettingsPageItem from './item'; import LocalSettingsPageItem from './item';
// Stylesheet imports // Stylesheet imports
import './style'; import './style.scss';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports // Stylesheet imports
import './style'; import './style.scss';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

View File

@ -1,5 +1,5 @@
import 'intl'; import 'intl';
import 'intl/locale-data/jsonp/en.js'; import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement'; import 'es6-symbol/implement';
import includes from 'array-includes'; import includes from 'array-includes';
import assign from 'object-assign'; import assign from 'object-assign';

View File

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
<div
className="account__avatar"
data-avatar-of="@alice"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
Object {
"backgroundImage": "url(/animated/alice.gif)",
"backgroundSize": "100px 100px",
"height": "100px",
"width": "100px",
}
}
/>
`;
exports[`<Avatar /> Still renders a still avatar 1`] = `
<div
className="account__avatar"
data-avatar-of="@alice"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
"backgroundSize": "100px 100px",
"height": "100px",
"width": "100px",
}
}
/>
`;

View File

@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div
className="account__avatar-overlay"
>
<div
className="account__avatar-overlay-base"
data-avatar-of="@alice"
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
}
}
/>
<div
className="account__avatar-overlay-overlay"
data-avatar-of="@eve@blackhat.lair"
style={
Object {
"backgroundImage": "url(/static/eve.jpg)",
}
}
/>
</div>
`;

View File

@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
<button
className="button button-secondary"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders a button element 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
<button
className="button"
disabled={true}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
<button
className="button button--block"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/>
`;
exports[`<Button /> renders the children 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
<p>
children
</p>
</button>
`;
exports[`<Button /> renders the given text 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
foo
</button>
`;
exports[`<Button /> renders the props.text instead of children 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
>
foo
</button>
`;

View File

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DisplayName /> renders display name + account name 1`] = `
<span
className="display-name"
>
<strong
className="display-name__html"
dangerouslySetInnerHTML={
Object {
"__html": "<p>Foo</p>",
}
}
/>
<span
className="display-name__account"
>
@
bar@baz
</span>
</span>
`;

View File

@ -0,0 +1,36 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import Avatar from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const size = 100;
describe('Autoplay', () => {
it('renders a animated avatar', () => {
const component = renderer.create(<Avatar account={account} animate size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
describe('Still', () => {
it('renders a still avatar', () => {
const component = renderer.create(<Avatar account={account} size={size} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
// TODO add autoplay test if possible
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import AvatarOverlay from '../avatar_overlay';
describe('<AvatarOverlay', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const friend = fromJS({
username: 'eve',
acct: 'eve@blackhat.lair',
display_name: 'Evelyn',
avatar: '/animated/eve.gif',
avatar_static: '/static/eve.jpg',
});
it('renders a overlay avatar', () => {
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,75 @@
import { shallow } from 'enzyme';
import React from 'react';
import renderer from 'react-test-renderer';
import Button from '../button';
describe('<Button />', () => {
it('renders a button element', () => {
const component = renderer.create(<Button />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the given text', () => {
const text = 'foo';
const component = renderer.create(<Button text={text} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('handles click events using the given handler', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} />);
button.find('button').simulate('click');
expect(handler.mock.calls.length).toEqual(1);
});
it('does not handle click events if props.disabled given', () => {
const handler = jest.fn();
const button = shallow(<Button onClick={handler} disabled />);
button.find('button').simulate('click');
expect(handler.mock.calls.length).toEqual(0);
});
it('renders a disabled attribute if props.disabled given', () => {
const component = renderer.create(<Button disabled />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the children', () => {
const children = <p>children</p>;
const component = renderer.create(<Button>{children}</Button>);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the props.text instead of children', () => {
const text = 'foo';
const children = <p>children</p>;
const component = renderer.create(<Button text={text}>{children}</Button>);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders class="button--block" if props.block given', () => {
const component = renderer.create(<Button block />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('adds class "button-secondary" if props.secondary given', () => {
const component = renderer.create(<Button secondary />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,18 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { fromJS } from 'immutable';
import DisplayName from '../display_name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {
const account = fromJS({
username: 'bar',
acct: 'bar@baz',
display_name_html: '<p>Foo</p>',
});
const component = renderer.create(<DisplayName account={account} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import Motion from 'react-motion/lib/Motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from './icon_button'; import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'react-motion/lib/Motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import Motion from 'react-motion/lib/Motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class IconButton extends React.PureComponent { export default class IconButton extends React.PureComponent {
@ -56,27 +57,26 @@ export default class IconButton extends React.PureComponent {
style.textAlign = 'left'; style.textAlign = 'left';
} }
const classes = ['icon-button']; const {
active,
animate,
className,
disabled,
expanded,
icon,
inverted,
overlay,
pressed,
tabIndex,
title,
} = this.props;
if (this.props.active) { const classes = classNames(className, 'icon-button', {
classes.push('active'); active,
} disabled,
inverted,
if (this.props.disabled) { overlayed: overlay,
classes.push('disabled'); });
}
if (this.props.inverted) {
classes.push('inverted');
}
if (this.props.overlay) {
classes.push('overlayed');
}
if (this.props.className) {
classes.push(this.props.className);
}
const flipDeg = this.props.flip ? -180 : -360; const flipDeg = this.props.flip ? -180 : -360;
const rotateDeg = this.props.active ? flipDeg : 0; const rotateDeg = this.props.active ? flipDeg : 0;
@ -90,23 +90,23 @@ export default class IconButton extends React.PureComponent {
damping: 7, damping: 7,
}; };
const motionStyle = { const motionStyle = {
rotate: this.props.animate ? spring(rotateDeg, springOpts) : 0, rotate: animate ? spring(rotateDeg, springOpts) : 0,
}; };
return ( return (
<Motion defaultStyle={motionDefaultStyle} style={motionStyle}> <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
{({ rotate }) => {({ rotate }) =>
<button <button
aria-label={this.props.title} aria-label={title}
aria-pressed={this.props.pressed} aria-pressed={pressed}
aria-expanded={this.props.expanded} aria-expanded={expanded}
title={this.props.title} title={title}
className={classes.join(' ')} className={classes}
onClick={this.handleClick} onClick={this.handleClick}
style={style} style={style}
tabIndex={this.props.tabIndex} tabIndex={tabIndex}
> >
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
{this.props.label} {this.props.label}
</button> </button>
} }

View File

@ -16,6 +16,7 @@ const messages = defineMessages({
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
@ -182,7 +183,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
{shareButton} {shareButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div> </div>
</div> </div>
); );

View File

@ -10,6 +10,7 @@ import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming'; import { connectUserStream } from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);

View File

@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events'; import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
const messages = defineMessages({ const messages = defineMessages({

View File

@ -2,7 +2,7 @@ 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 IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
export default class Warning extends React.PureComponent { export default class Warning extends React.PureComponent {

View File

@ -1,15 +0,0 @@
import { connect } from 'react-redux';
import AutosuggestStatus from '../components/autosuggest_status';
import { makeGetStatus } from '../../../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { id }) => ({
status: getStatus(state, id),
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestStatus);

View File

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import { changeComposeSensitivity } from '../../../actions/compose'; import { changeComposeSensitivity } from '../../../actions/compose';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';

View File

@ -10,7 +10,7 @@ import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container'; import SearchContainer from './containers/search_container';
import Motion from 'react-motion/lib/Motion'; import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container'; import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose'; import { changeComposing } from '../../actions/compose';

View File

@ -0,0 +1,61 @@
import emojify from '../emoji';
describe('emoji', () => {
describe('.emojify', () => {
it('ignores unknown shortcodes', () => {
expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
});
it('ignores shortcodes inside of tags', () => {
expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).toEqual('hello>');
expect(emojify('<hello')).toEqual('<hello');
});
it('works with unclosed shortcodes', () => {
expect(emojify('smile:')).toEqual('smile:');
expect(emojify(':smile')).toEqual(':smile');
});
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
expect(emojify('👨‍👩‍👧‍👧')).toEqual(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
expect(emojify('👩‍👩‍👦')).toEqual('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
expect(emojify('\u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
});
it('ignores unicode inside of tags', () => {
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
expect(emojify('👌 🌈 💕')).toEqual('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('🕉️')).toEqual('<img draggable="false" class="emojione" alt="🕉️" title="" src="/emoji/1f549.svg" />');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).toEqual('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
});
});
});

View File

@ -0,0 +1,130 @@
import { pick } from 'lodash';
import { emojiIndex } from 'emoji-mart';
import { search } from '../emoji_mart_search_light';
const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
describe('emoji_index', () => {
it('should give same result for emoji_index_light and emoji-mart', () => {
const expected = [
{
id: 'pineapple',
unified: '1f34d',
native: '🍍',
},
];
expect(search('pineapple').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
});
it('orders search results correctly', () => {
const expected = [
{
id: 'apple',
unified: '1f34e',
native: '🍎',
},
{
id: 'pineapple',
unified: '1f34d',
native: '🍍',
},
{
id: 'green_apple',
unified: '1f34f',
native: '🍏',
},
{
id: 'iphone',
unified: '1f4f1',
native: '📱',
},
];
expect(search('apple').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
});
it('handles custom emoji', () => {
const custom = [
{
id: 'mastodon',
name: 'mastodon',
short_names: ['mastodon'],
text: '',
emoticons: [],
keywords: ['mastodon'],
imageUrl: 'http://example.com',
custom: true,
},
];
search('', { custom });
emojiIndex.search('', { custom });
const expected = [
{
id: 'mastodon',
custom: true,
},
];
expect(search('masto').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
});
it('should filter only emojis we care about, exclude pineapple', () => {
const emojisToShowFilter = unified => unified !== '1F34D';
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.toContain('pineapple');
});
it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] })).toEqual([]);
expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
});
it('does an emoji whose unified name is irregular', () => {
const expected = [
{
'id': 'water_polo',
'unified': '1f93d',
'native': '🤽',
},
{
'id': 'man-playing-water-polo',
'unified': '1f93d-200d-2642-fe0f',
'native': '🤽‍♂️',
},
{
'id': 'woman-playing-water-polo',
'unified': '1f93d-200d-2640-fe0f',
'native': '🤽‍♀️',
},
];
expect(search('polo').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
});
it('can search for thinking_face', () => {
const expected = [
{
id: 'thinking_face',
unified: '1f914',
native: '🤔',
},
];
expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
});
it('can search for woman-facepalming', () => {
const expected = [
{
id: 'woman-facepalming',
unified: '1f926-200d-2640-fe0f',
native: '🤦‍♀️',
},
];
expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
});
});

View File

@ -9,7 +9,8 @@ const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
const emojiMap = require('./emoji_map.json'); const emojiMap = require('./emoji_map.json');
const { emojiIndex } = require('emoji-mart'); const { emojiIndex } = require('emoji-mart');
const emojiMartData = require('emoji-mart/dist/data').default; const { default: emojiMartData } = require('emoji-mart/dist/data');
const excluded = ['®', '©', '™']; const excluded = ['®', '©', '™'];
const skins = ['🏻', '🏼', '🏽', '🏾', '🏿']; const skins = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {}; const shortcodeMap = {};

View File

@ -48,6 +48,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
let media = ''; let media = '';
let mediaIcon = null; let mediaIcon = null;
let applicationLink = ''; let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@ -85,6 +87,23 @@ export default class DetailedStatus extends ImmutablePureComponent {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
} }
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
if (status.get('visibility') === 'private') {
reblogLink = <i className={`fa fa-${reblogIcon}`} />;
} else {
reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<i className={`fa fa-${reblogIcon}`} />
<span className='detailed-status__reblogs'>
<FormattedNumber value={status.get('reblogs_count')} />
</span>
</Link>);
}
return ( return (
<div className='detailed-status'> <div className='detailed-status'>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
@ -101,12 +120,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<i className='fa fa-retweet' />
<span className='detailed-status__reblogs'>
<FormattedNumber value={status.get('reblogs_count')} />
</span>
</Link> · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<i className='fa fa-star' /> <i className='fa fa-star' />
<span className='detailed-status__favorites'> <span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} /> <FormattedNumber value={status.get('favourites_count')} />

View File

@ -1,14 +1,18 @@
import { expect } from 'chai';
import { mount } from 'enzyme';
import sinon from 'sinon';
import React from 'react'; import React from 'react';
import Column from '../../../../../../app/javascript/mastodon/features/ui/components/column'; import { mount } from 'enzyme';
import ColumnHeader from '../../../../../../app/javascript/mastodon/features/ui/components/column_header'; import Column from '../column';
import ColumnHeader from '../column_header';
describe('<Column />', () => { describe('<Column />', () => {
describe('<ColumnHeader /> click handler', () => { describe('<ColumnHeader /> click handler', () => {
const originalRaf = global.requestAnimationFrame;
beforeEach(() => { beforeEach(() => {
global.requestAnimationFrame = sinon.spy(); global.requestAnimationFrame = jest.fn();
});
afterAll(() => {
global.requestAnimationFrame = originalRaf;
}); });
it('runs the scroll animation if the column contains scrollable content', () => { it('runs the scroll animation if the column contains scrollable content', () => {
@ -18,13 +22,13 @@ describe('<Column />', () => {
</Column> </Column>
); );
wrapper.find(ColumnHeader).simulate('click'); wrapper.find(ColumnHeader).simulate('click');
expect(global.requestAnimationFrame.called).to.equal(true); expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
}); });
it('does not try to scroll if there is no scrollable content', () => { it('does not try to scroll if there is no scrollable content', () => {
const wrapper = mount(<Column heading='notifications' />); const wrapper = mount(<Column heading='notifications' />);
wrapper.find(ColumnHeader).simulate('click'); wrapper.find(ColumnHeader).simulate('click');
expect(global.requestAnimationFrame.called).to.equal(false); expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
}); });
}); });
}); });

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Motion from 'react-motion/lib/Motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';

View File

@ -0,0 +1,34 @@
// Like react-motion's Motion, but checks to see if the user prefers
// reduced motion and uses a cross-fade in those cases.
import Motion from 'react-motion/lib/Motion';
import { connect } from 'react-redux';
const stylesToKeep = ['opacity', 'backgroundOpacity'];
const extractValue = (value) => {
// This is either an object with a "val" property or it's a number
return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
};
const mapStateToProps = (state, ownProps) => {
const reduceMotion = state.getIn(['meta', 'reduce_motion']);
if (reduceMotion) {
const { style, defaultStyle } = ownProps;
Object.keys(style).forEach(key => {
if (stylesToKeep.includes(key)) {
return;
}
// If it's setting an x or height or scale or some other value, we need
// to preserve the end-state value without actually animating it
style[key] = defaultStyle[key] = extractValue(style[key]);
});
return { style, defaultStyle };
}
return {};
};
export default connect(mapStateToProps)(Motion);

View File

@ -184,6 +184,7 @@
"status.load_more": "Carrega més", "status.load_more": "Carrega més",
"status.media_hidden": "Multimèdia amagat", "status.media_hidden": "Multimèdia amagat",
"status.mention": "Esmentar @{name}", "status.mention": "Esmentar @{name}",
"status.more": "Més",
"status.mute_conversation": "Silenciar conversació", "status.mute_conversation": "Silenciar conversació",
"status.open": "Ampliar aquest estat", "status.open": "Ampliar aquest estat",
"status.pin": "Fixat en el perfil", "status.pin": "Fixat en el perfil",

View File

@ -179,6 +179,7 @@
"status.load_more": "Load more", "status.load_more": "Load more",
"status.media_hidden": "Media hidden", "status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}", "status.mention": "Mention @{name}",
"status.more": "More",
"status.mute_conversation": "Mute conversation", "status.mute_conversation": "Mute conversation",
"status.open": "Expand this status", "status.open": "Expand this status",
"status.pin": "Pin on profile", "status.pin": "Pin on profile",

View File

@ -179,6 +179,7 @@
"status.load_more": "Cargar más", "status.load_more": "Cargar más",
"status.media_hidden": "Contenido multimedia oculto", "status.media_hidden": "Contenido multimedia oculto",
"status.mention": "Mencionar", "status.mention": "Mencionar",
"status.more": "Más",
"status.mute_conversation": "Silenciar conversación", "status.mute_conversation": "Silenciar conversación",
"status.open": "Expandir estado", "status.open": "Expandir estado",
"status.pin": "Fijar", "status.pin": "Fijar",

View File

@ -179,6 +179,7 @@
"status.load_more": "Charger plus", "status.load_more": "Charger plus",
"status.media_hidden": "Média caché", "status.media_hidden": "Média caché",
"status.mention": "Mentionner", "status.mention": "Mentionner",
"status.more": "Plus",
"status.mute_conversation": "Masquer la conversation", "status.mute_conversation": "Masquer la conversation",
"status.open": "Déplier ce statut", "status.open": "Déplier ce statut",
"status.pin": "Épingler sur le profil", "status.pin": "Épingler sur le profil",

View File

@ -18,7 +18,7 @@
"account.unblock_domain": "{domain} 숨김 해제", "account.unblock_domain": "{domain} 숨김 해제",
"account.unfollow": "팔로우 해제", "account.unfollow": "팔로우 해제",
"account.unmute": "뮤트 해제", "account.unmute": "뮤트 해제",
"account.view_full_profile": "View full profile", "account.view_full_profile": "전체 프로필 보기",
"boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.", "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
"bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.body": "Something went wrong while loading this component.",
"bundle_column_error.retry": "Try again", "bundle_column_error.retry": "Try again",
@ -33,7 +33,7 @@
"column.home": "홈", "column.home": "홈",
"column.mutes": "뮤트 중인 사용자", "column.mutes": "뮤트 중인 사용자",
"column.notifications": "알림", "column.notifications": "알림",
"column.pins": "고정된 Toot", "column.pins": "고정된 ",
"column.public": "연합 타임라인", "column.public": "연합 타임라인",
"column_back_button.label": "돌아가기", "column_back_button.label": "돌아가기",
"column_header.hide_settings": "Hide settings", "column_header.hide_settings": "Hide settings",
@ -47,7 +47,7 @@
"compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.", "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
"compose_form.lock_disclaimer.lock": "비공개", "compose_form.lock_disclaimer.lock": "비공개",
"compose_form.placeholder": "지금 무엇을 하고 있나요?", "compose_form.placeholder": "지금 무엇을 하고 있나요?",
"compose_form.publish": "Toot", "compose_form.publish": "",
"compose_form.publish_loud": "{publish}!", "compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "이 미디어를 민감한 미디어로 취급", "compose_form.sensitive": "이 미디어를 민감한 미디어로 취급",
"compose_form.spoiler": "텍스트 숨기기", "compose_form.spoiler": "텍스트 숨기기",
@ -63,8 +63,8 @@
"confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?", "confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?",
"confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"embed.instructions": "Embed this status on your website by copying the code below.", "embed.instructions": "아래의 코드를 복사하여 대화를 원하는 곳으로 퍼가세요.",
"embed.preview": "Here is what it will look like:", "embed.preview": "다음과 같이 표시됩니다:",
"emoji_button.activity": "활동", "emoji_button.activity": "활동",
"emoji_button.custom": "Custom", "emoji_button.custom": "Custom",
"emoji_button.flags": "국기", "emoji_button.flags": "국기",
@ -82,7 +82,6 @@
"empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!", "empty_column.community": "로컬 타임라인에 아무 것도 없습니다. 아무거나 적어 보세요!",
"empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.", "empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.",
"empty_column.home": "아직 아무도 팔로우 하고 있지 않습니다. {public}를 보러 가거나, 검색하여 다른 사용자를 찾아 보세요.", "empty_column.home": "아직 아무도 팔로우 하고 있지 않습니다. {public}를 보러 가거나, 검색하여 다른 사용자를 찾아 보세요.",
"empty_column.home.inactivity": "홈 피드에 아무 것도 없습니다. 한동안 활동하지 않은 경우 곧 원래대로 돌아올 것입니다.",
"empty_column.home.public_timeline": "연합 타임라인", "empty_column.home.public_timeline": "연합 타임라인",
"empty_column.notifications": "아직 알림이 없습니다. 다른 사람과 대화를 시작해 보세요!", "empty_column.notifications": "아직 알림이 없습니다. 다른 사람과 대화를 시작해 보세요!",
"empty_column.public": "여기엔 아직 아무 것도 없습니다! 공개적으로 무언가 포스팅하거나, 다른 인스턴스 유저를 팔로우 해서 가득 채워보세요!", "empty_column.public": "여기엔 아직 아무 것도 없습니다! 공개적으로 무언가 포스팅하거나, 다른 인스턴스 유저를 팔로우 해서 가득 채워보세요!",
@ -113,7 +112,7 @@
"navigation_bar.info": "이 인스턴스에 대해서", "navigation_bar.info": "이 인스턴스에 대해서",
"navigation_bar.logout": "로그아웃", "navigation_bar.logout": "로그아웃",
"navigation_bar.mutes": "뮤트 중인 사용자", "navigation_bar.mutes": "뮤트 중인 사용자",
"navigation_bar.pins": "고정된 Toot", "navigation_bar.pins": "고정된 ",
"navigation_bar.preferences": "사용자 설정", "navigation_bar.preferences": "사용자 설정",
"navigation_bar.public_timeline": "연합 타임라인", "navigation_bar.public_timeline": "연합 타임라인",
"notification.favourite": "{name}님이 즐겨찾기 했습니다", "notification.favourite": "{name}님이 즐겨찾기 했습니다",
@ -159,29 +158,34 @@
"privacy.public.long": "공개 타임라인에 표시", "privacy.public.long": "공개 타임라인에 표시",
"privacy.public.short": "공개", "privacy.public.short": "공개",
"privacy.unlisted.long": "공개 타임라인에 표시하지 않음", "privacy.unlisted.long": "공개 타임라인에 표시하지 않음",
"privacy.unlisted.short": "Unlisted", "privacy.unlisted.short": "타임라인에 비표시",
"relative_time.days": "{number}일 전",
"relative_time.hours": "{number}시간 전",
"relative_time.just_now": "방금",
"relative_time.minutes": "{number}분 전",
"relative_time.seconds": "{number}초 전",
"reply_indicator.cancel": "취소", "reply_indicator.cancel": "취소",
"report.placeholder": "코멘트", "report.placeholder": "코멘트",
"report.submit": "신고하기", "report.submit": "신고하기",
"report.target": "문제가 된 사용자", "report.target": "문제가 된 사용자",
"search.placeholder": "검색", "search.placeholder": "검색",
"search_popout.search_format": "Advanced search format", "search_popout.search_format": "고급 검색 방법",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "해시태그",
"search_popout.tips.status": "status", "search_popout.tips.status": "",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "단순한 텍스트 검색은 관계된 프로필 이름, 유저 이름 그리고 해시태그를 표시합니다",
"search_popout.tips.user": "user", "search_popout.tips.user": "유저",
"search_results.total": "{count, number}건의 결과", "search_results.total": "{count, number}건의 결과",
"standalone.public_title": "A look inside...", "standalone.public_title": "A look inside...",
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
"status.delete": "삭제", "status.delete": "삭제",
"status.embed": "Embed", "status.embed": "공유하기",
"status.favourite": "즐겨찾기", "status.favourite": "즐겨찾기",
"status.load_more": "더 보기", "status.load_more": "더 보기",
"status.media_hidden": "미디어 숨겨짐", "status.media_hidden": "미디어 숨겨짐",
"status.mention": "답장", "status.mention": "답장",
"status.mute_conversation": "이 대화를 뮤트", "status.mute_conversation": "이 대화를 뮤트",
"status.open": "상세 정보 표시", "status.open": "상세 정보 표시",
"status.pin": "Pin on profile", "status.pin": "고정",
"status.reblog": "부스트", "status.reblog": "부스트",
"status.reblogged_by": "{name}님이 부스트 했습니다", "status.reblogged_by": "{name}님이 부스트 했습니다",
"status.reply": "답장", "status.reply": "답장",
@ -193,7 +197,7 @@
"status.show_less": "숨기기", "status.show_less": "숨기기",
"status.show_more": "더 보기", "status.show_more": "더 보기",
"status.unmute_conversation": "이 대화의 뮤트 해제하기", "status.unmute_conversation": "이 대화의 뮤트 해제하기",
"status.unpin": "Unpin from profile", "status.unpin": "고정 해제",
"tabs_bar.compose": "포스트", "tabs_bar.compose": "포스트",
"tabs_bar.federated_timeline": "연합", "tabs_bar.federated_timeline": "연합",
"tabs_bar.home": "홈", "tabs_bar.home": "홈",
@ -212,5 +216,9 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "video.play": "Play",
"video.unmute": "Unmute sound" "video.unmute": "Unmute sound",
"video_player.expand": "Expand video",
"video_player.toggle_sound": "Toggle sound",
"video_player.toggle_visible": "Toggle visibility",
"video_player.video_error": "Video could not be played"
} }

View File

@ -179,6 +179,7 @@
"status.load_more": "Cargar mai", "status.load_more": "Cargar mai",
"status.media_hidden": "Mèdia rescondut", "status.media_hidden": "Mèdia rescondut",
"status.mention": "Mencionar", "status.mention": "Mencionar",
"status.more": "Mai",
"status.mute_conversation": "Rescondre la conversacion", "status.mute_conversation": "Rescondre la conversacion",
"status.open": "Desplegar aqueste estatut", "status.open": "Desplegar aqueste estatut",
"status.pin": "Penjar al perfil", "status.pin": "Penjar al perfil",

View File

@ -179,6 +179,7 @@
"status.load_more": "Załaduj więcej", "status.load_more": "Załaduj więcej",
"status.media_hidden": "Zawartość multimedialna ukryta", "status.media_hidden": "Zawartość multimedialna ukryta",
"status.mention": "Wspomnij o @{name}", "status.mention": "Wspomnij o @{name}",
"status.more": "Więcej",
"status.mute_conversation": "Wycisz konwersację", "status.mute_conversation": "Wycisz konwersację",
"status.open": "Rozszerz ten wpis", "status.open": "Rozszerz ten wpis",
"status.pin": "Przypnij do profilu", "status.pin": "Przypnij do profilu",

View File

@ -1,6 +1,5 @@
import * as OfflinePluginRuntime from 'offline-plugin/runtime';
import * as WebPushSubscription from './web_push_subscription'; import * as WebPushSubscription from './web_push_subscription';
import Mastodon from 'mastodon/containers/mastodon'; import Mastodon from './containers/mastodon';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import ready from './ready'; import ready from './ready';
@ -25,7 +24,7 @@ function main() {
ReactDOM.render(<Mastodon {...props} />, mountNode); ReactDOM.render(<Mastodon {...props} />, mountNode);
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
OfflinePluginRuntime.install(); require('offline-plugin/runtime').install();
WebPushSubscription.register(); WebPushSubscription.register();
} }
perf.stop('main()'); perf.stop('main()');

View File

@ -31,10 +31,10 @@ const initialTimeline = ImmutableMap({
}); });
const normalizeTimeline = (state, timeline, statuses, next) => { const normalizeTimeline = (state, timeline, statuses, next) => {
const ids = ImmutableList(statuses.map(status => status.get('id'))); const oldIds = state.getIn([timeline, 'items'], ImmutableList());
const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
const wasLoaded = state.getIn([timeline, 'loaded']); const wasLoaded = state.getIn([timeline, 'loaded']);
const hadNext = state.getIn([timeline, 'next']); const hadNext = state.getIn([timeline, 'next']);
const oldIds = state.getIn([timeline, 'items'], ImmutableList());
return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
mMap.set('loaded', true); mMap.set('loaded', true);
@ -45,8 +45,8 @@ const normalizeTimeline = (state, timeline, statuses, next) => {
}; };
const appendNormalizedTimeline = (state, timeline, statuses, next) => { const appendNormalizedTimeline = (state, timeline, statuses, next) => {
const ids = ImmutableList(statuses.map(status => status.get('id')));
const oldIds = state.getIn([timeline, 'items'], ImmutableList()); const oldIds = state.getIn([timeline, 'items'], ImmutableList());
const ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
mMap.set('isLoading', false); mMap.set('isLoading', false);

View File

@ -0,0 +1,5 @@
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
const adapter = new Adapter();
configure({ adapter });

View File

@ -2,7 +2,8 @@ import loadPolyfills from '../mastodon/load_polyfills';
// import default stylesheet with variables // import default stylesheet with variables
require('font-awesome/css/font-awesome.css'); require('font-awesome/css/font-awesome.css');
import 'styles/application';
import '../styles/application.scss';
require.context('../images/', true); require.context('../images/', true);

View File

@ -1,6 +1,9 @@
import { start } from 'rails-ujs'; import { start } from 'rails-ujs';
import 'font-awesome/css/font-awesome.css';
// import common styling // import common styling
require('../styles/common.scss'); require('../styles/common.scss');
require.context('../images/', true);
start(); start();

View File

@ -1,2 +1,2 @@
import 'packs/application'; import '../../packs/application';
import 'themes/spin/style'; import './style.scss';

View File

@ -100,11 +100,24 @@ class FeedManager
end end
def populate_feed(account) def populate_feed(account)
prepopulate_limit = FeedManager::MAX_ITEMS / 4 added = 0
statuses = Status.as_home_timeline(account).order(account_id: :desc).limit(prepopulate_limit) limit = FeedManager::MAX_ITEMS / 2
statuses.reverse_each do |status| max_id = nil
next if filter_from_home?(status, account)
add_to_feed(:home, account, status) loop do
statuses = Status.as_home_timeline(account)
.paginate_by_max_id(limit, max_id)
break if statuses.empty?
statuses.each do |status|
next if filter_from_home?(status, account)
added += 1 if add_to_feed(:home, account, status)
end
break unless added.zero?
max_id = statuses.last.id
end end
end end
@ -167,13 +180,19 @@ class FeedManager
# either action is appropriate. # either action is appropriate.
def add_to_feed(timeline_type, account, status) def add_to_feed(timeline_type, account, status)
timeline_key = key(timeline_type, account.id) timeline_key = key(timeline_type, account.id)
reblog_key = key(timeline_type, account.id, 'reblogs') reblog_key = key(timeline_type, account.id, 'reblogs')
if status.reblog? if status.reblog?
reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
# If the original status or a reblog of it is within # If the original status or a reblog of it is within
# REBLOG_FALLOFF statuses from the top, do not re-insert it into # REBLOG_FALLOFF statuses from the top, do not re-insert it into
# the feed # the feed
rank = redis.zrevrank(timeline_key, status.reblog_of_id) rank = redis.zrevrank(timeline_key, status.reblog_of_id)
redis.sadd(reblog_set_key, status.reblog_of_id) unless rank.nil?
redis.sadd(reblog_set_key, status.id)
return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id) reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
@ -194,7 +213,7 @@ class FeedManager
# do so if appropriate. # do so if appropriate.
def remove_from_feed(timeline_type, account, status) def remove_from_feed(timeline_type, account, status)
timeline_key = key(timeline_type, account.id) timeline_key = key(timeline_type, account.id)
reblog_key = key(timeline_type, account.id, 'reblogs') reblog_key = key(timeline_type, account.id, 'reblogs')
if status.reblog? if status.reblog?
# 1. If the reblogging status is not in the feed, stop. # 1. If the reblogging status is not in the feed, stop.
@ -204,12 +223,21 @@ class FeedManager
# 2. Remove the reblogged status from the `:reblogs` zset. # 2. Remove the reblogged status from the `:reblogs` zset.
redis.zrem(reblog_key, status.reblog_of_id) redis.zrem(reblog_key, status.reblog_of_id)
# 3. Add the reblogged status to the feed using the reblogging # 3. Remove reblog from set of this status's reblogs, and
# status' ID as its score, and the reblogged status' ID as its # re-insert another reblog or original into the feed if
# value. # one remains in the set
redis.zadd(timeline_key, status.id, status.reblog_of_id) reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
redis.srem(reblog_set_key, status.id)
other_reblog = redis.srandmember(reblog_set_key)
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
# 4. Remove the reblogging status from the feed (as normal) # 4. Remove the reblogging status from the feed (as normal)
# (outside conditional)
else
# If the original is getting deleted, no use for reblog references
redis.del(key(timeline_type, account.id, "reblogs:#{status.id}"))
end end
redis.zrem(timeline_key, status.id) redis.zrem(timeline_key, status.id)

View File

@ -23,6 +23,7 @@ class UserSettingsDecorator
user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal')
user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal')
user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif')
user.settings['reduce_motion'] = reduce_motion_preference if change?('setting_reduce_motion')
user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui')
user.settings['noindex'] = noindex_preference if change?('setting_noindex') user.settings['noindex'] = noindex_preference if change?('setting_noindex')
user.settings['theme'] = theme_preference if change?('setting_theme') user.settings['theme'] = theme_preference if change?('setting_theme')
@ -64,6 +65,10 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_auto_play_gif' boolean_cast_setting 'setting_auto_play_gif'
end end
def reduce_motion_preference
boolean_cast_setting 'setting_reduce_motion'
end
def noindex_preference def noindex_preference
boolean_cast_setting 'setting_noindex' boolean_cast_setting 'setting_noindex'
end end

View File

@ -102,6 +102,10 @@ class User < ApplicationRecord
settings.auto_play_gif settings.auto_play_gif
end end
def setting_reduce_motion
settings.reduce_motion
end
def setting_system_font_ui def setting_system_font_ui
settings.system_font_ui settings.system_font_ui
end end

View File

@ -25,6 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:boost_modal] = object.current_account.user.setting_boost_modal store[:boost_modal] = object.current_account.user.setting_boost_modal
store[:delete_modal] = object.current_account.user.setting_delete_modal store[:delete_modal] = object.current_account.user.setting_delete_modal
store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif
store[:reduce_motion] = object.current_account.user.setting_reduce_motion
end end
store store

View File

@ -35,6 +35,7 @@
.fields-group .fields-group
= f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label
= f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label
= f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
.actions .actions

View File

@ -19,15 +19,14 @@
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
.e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
= Formatter.instance.format(status, custom_emojify: true) = Formatter.instance.format(status, custom_emojify: true)
- if !status.media_attachments.empty? - if !status.media_attachments.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?
- video = status.media_attachments.first - video = status.media_attachments.first
%div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}>< %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}<
- else - else
%div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}>< %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}<
- elsif status.preview_cards.first - elsif status.preview_cards.first
%div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}>< %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}<
.detailed-status__meta .detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 } %data.dt-published{ value: status.created_at.to_time.iso8601 }
@ -40,9 +39,16 @@
- else - else
= link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener' = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
· ·
%span< - if status.direct_visibility?
= fa_icon('retweet') %span<
%span= status.reblogs_count = fa_icon('envelope')
- elsif status.private_visibility?
%span<
= fa_icon('lock')
- else
%span<
= fa_icon('retweet')
%span= status.reblogs_count
· ·
%span< %span<
= fa_icon('star') = fa_icon('star')

View File

@ -6,8 +6,9 @@ class Scheduler::FeedCleanupScheduler
def perform def perform
redis.pipelined do redis.pipelined do
inactive_users.pluck(:account_id).each do |account_id| inactive_users.each do |account_id|
redis.del(FeedManager.instance.key(:home, account_id)) redis.del(FeedManager.instance.key(:home, account_id))
redis.del(FeedManager.instance.key(:home, account_id, 'reblogs'))
end end
end end
end end
@ -15,7 +16,7 @@ class Scheduler::FeedCleanupScheduler
private private
def inactive_users def inactive_users
User.confirmed.inactive @inactive_users ||= User.confirmed.inactive.pluck(:account_id)
end end
def redis def redis

View File

@ -120,9 +120,9 @@ oc:
destroyed_msg: Nòta de moderacion ben suprimida! destroyed_msg: Nòta de moderacion ben suprimida!
custom_emojis: custom_emojis:
copied_msg: Còpia locale de lemoji ben creada copied_msg: Còpia locala de lemoji ben creada
copy: Copiar copy: Copiar
copy_failed_msg: Fracàs de la còpia locale de lemoji copy_failed_msg: Fracàs de la còpia locala de lemoji
created_msg: Emoji ben creat! created_msg: Emoji ben creat!
delete: Suprimir delete: Suprimir
destroyed_msg: Emojo ben suprimit! destroyed_msg: Emojo ben suprimit!

View File

@ -44,6 +44,7 @@ en:
setting_default_sensitive: Always mark media as sensitive setting_default_sensitive: Always mark media as sensitive
setting_delete_modal: Show confirmation dialog before deleting a toot setting_delete_modal: Show confirmation dialog before deleting a toot
setting_noindex: Opt-out of search engine indexing setting_noindex: Opt-out of search engine indexing
setting_reduce_motion: Reduce motion in animations
setting_system_font_ui: Use system's default font setting_system_font_ui: Use system's default font
setting_theme: Site theme setting_theme: Site theme
setting_unfollow_modal: Show confirmation dialog before unfollowing someone setting_unfollow_modal: Show confirmation dialog before unfollowing someone

View File

@ -38,6 +38,7 @@ oc:
otp_attempt: Còdi Two-factor otp_attempt: Còdi Two-factor
password: Senhal password: Senhal
setting_auto_play_gif: Lectura automatica dels GIFS animats setting_auto_play_gif: Lectura automatica dels GIFS animats
setting_reduce_motion: Reduire la velocitat de las animacions
setting_boost_modal: Afichar una fenèstra de confirmacion abans de partejar un estatut setting_boost_modal: Afichar una fenèstra de confirmacion abans de partejar un estatut
setting_default_privacy: Confidencialitat de las publicacions setting_default_privacy: Confidencialitat de las publicacions
setting_default_sensitive: Totjorn marcar los mèdias coma sensibles setting_default_sensitive: Totjorn marcar los mèdias coma sensibles

View File

@ -48,6 +48,7 @@ pl:
setting_default_sensitive: Zawsze oznaczaj zawartość multimedialną jako wrażliwą setting_default_sensitive: Zawsze oznaczaj zawartość multimedialną jako wrażliwą
setting_delete_modal: Pytaj o potwierdzenie przed usunięciem wpisu setting_delete_modal: Pytaj o potwierdzenie przed usunięciem wpisu
setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych
setting_reduce_motion: Ogranicz ruch w animacjach
setting_system_font_ui: Używaj domyślnej czcionki systemu setting_system_font_ui: Używaj domyślnej czcionki systemu
setting_theme: Motyw strony setting_theme: Motyw strony
setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia

View File

@ -22,6 +22,7 @@ defaults: &defaults
boost_modal: false boost_modal: false
delete_modal: true delete_modal: true
auto_play_gif: false auto_play_gif: false
reduce_motion: false
system_font_ui: false system_font_ui: false
noindex: false noindex: false
theme: 'default' theme: 'default'

View File

@ -26,7 +26,7 @@ class StatusIdsToTimestampIds < ActiveRecord::Migration[5.1]
SELECT setval('statuses_id_seq', (SELECT MAX(id) FROM statuses)); SELECT setval('statuses_id_seq', (SELECT MAX(id) FROM statuses));
ALTER TABLE statuses ALTER TABLE statuses
ALTER COLUMN id ALTER COLUMN id
SET DEFAULT nextval('statuses_id_seq');" SET DEFAULT nextval('statuses_id_seq');
SQL SQL
end end
end end

17
jest.config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
projects: [
'<rootDir>/app/javascript/mastodon',
],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/vendor/',
'<rootDir>/config/',
'<rootDir>/log/',
'<rootDir>/public/',
'<rootDir>/tmp/',
],
setupFiles: [
'raf/polyfill',
],
setupTestFrameworkScriptFile: '<rootDir>/app/javascript/mastodon/test_setup.js',
};

View File

@ -21,7 +21,7 @@ module Mastodon
end end
def flags def flags
'rc2' 'rc3'
end end
def to_a def to_a

View File

@ -7,9 +7,9 @@
"build:production": "cross-env RAILS_ENV=production ./bin/webpack", "build:production": "cross-env RAILS_ENV=production ./bin/webpack",
"manage:translations": "node ./config/webpack/translationRunner.js", "manage:translations": "node ./config/webpack/translationRunner.js",
"start": "node ./streaming/index.js", "start": "node ./streaming/index.js",
"test": "npm run test:lint && npm run test:mocha", "test": "npm run test:lint && npm run test:jest",
"test:lint": "eslint -c .eslintrc.yml --ext=js app/javascript/ config/webpack/ spec/javascript/ streaming/", "test:lint": "eslint -c .eslintrc.yml --ext=js app/javascript/ config/webpack/ spec/javascript/ streaming/",
"test:mocha": "cross-env NODE_ENV=test mocha --require ./spec/javascript/setup.js --compilers js:babel-register ./spec/javascript/components/**/*.test.js", "test:jest": "cross-env NODE_ENV=test jest",
"postinstall": "npm rebuild node-sass" "postinstall": "npm rebuild node-sass"
}, },
"repository": { "repository": {
@ -58,6 +58,7 @@
"immutable": "^3.8.1", "immutable": "^3.8.1",
"intersection-observer": "^0.4.0", "intersection-observer": "^0.4.0",
"intl": "^1.2.5", "intl": "^1.2.5",
"intl-messageformat": "^2.1.0",
"intl-relativeformat": "^2.0.0", "intl-relativeformat": "^2.0.0",
"is-nan": "^1.2.1", "is-nan": "^1.2.1",
"js-yaml": "^3.9.0", "js-yaml": "^3.9.0",
@ -119,22 +120,37 @@
}, },
"devDependencies": { "devDependencies": {
"babel-eslint": "^7.2.3", "babel-eslint": "^7.2.3",
"chai": "^4.1.0",
"chai-enzyme": "^0.8.0",
"enzyme": "^3.0.0", "enzyme": "^3.0.0",
"enzyme-adapter-react-16": "^1.0.0", "enzyme-adapter-react-16": "^1.0.0",
"eslint": "^3.19.0", "eslint": "^3.19.0",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-jsx-a11y": "^4.0.0", "eslint-plugin-jsx-a11y": "^4.0.0",
"eslint-plugin-react": "^6.10.3", "eslint-plugin-react": "^6.10.3",
"jsdom": "^11.1.0", "jest": "^21.2.1",
"mocha": "^3.4.1", "raf": "^3.4.0",
"react-intl-translations-manager": "^5.0.0", "react-intl-translations-manager": "^5.0.0",
"react-test-renderer": "^16.0.0", "react-test-renderer": "^16.0.0",
"sinon": "^2.3.7",
"webpack-dev-server": "^2.6.1", "webpack-dev-server": "^2.6.1",
"yargs": "^8.0.2" "yargs": "^8.0.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "*" "fsevents": "*"
},
"jest": {
"projects": [
"<rootDir>/app/javascript/mastodon"
],
"testPathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/vendor/",
"<rootDir>/config/",
"<rootDir>/log/",
"<rootDir>/public/",
"<rootDir>/tmp/"
],
"setupFiles": [
"raf/polyfill"
],
"setupTestFrameworkScriptFile": "<rootDir>/app/javascript/mastodon/test_setup.js"
} }
} }

View File

@ -1,3 +0,0 @@
---
env:
mocha: true

View File

@ -1,44 +0,0 @@
import React from 'react';
import Avatar from '../../../app/javascript/mastodon/components/avatar';
import { expect } from 'chai';
import { render } from 'enzyme';
import { fromJS } from 'immutable';
describe('<Avatar />', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const size = 100;
const animated = render(<Avatar account={account} animate size={size} />);
const still = render(<Avatar account={account} size={size} />);
// Autoplay
xit('renders a div element with the given src as background', () => {
expect(animated.find('div')).to.have.style('background-image', `url(${account.get('avatar')})`);
});
xit('renders a div element of the given size', () => {
['width', 'height'].map((attr) => {
expect(animated.find('div')).to.have.style(attr, `${size}px`);
});
});
// Still
xit('renders a div element with the given static src as background if not autoplay', () => {
expect(still.find('div')).to.have.style('background-image', `url(${account.get('avatar_static')})`);
});
xit('renders a div element of the given size if not autoplay', () => {
['width', 'height'].map((attr) => {
expect(still.find('div')).to.have.style(attr, `${size}px`);
});
});
// TODO add autoplay test if possible
});

View File

@ -1,36 +0,0 @@
import React from 'react';
import AvatarOverlay from '../../../app/javascript/mastodon/components/avatar_overlay';
import { expect } from 'chai';
import { render } from 'enzyme';
import { fromJS } from 'immutable';
describe('<Avatar />', () => {
const account = fromJS({
username: 'alice',
acct: 'alice',
display_name: 'Alice',
avatar: '/animated/alice.gif',
avatar_static: '/static/alice.jpg',
});
const friend = fromJS({
username: 'eve',
acct: 'eve@blackhat.lair',
display_name: 'Evelyn',
avatar: '/animated/eve.gif',
avatar_static: '/static/eve.jpg',
});
const overlay = render(<AvatarOverlay account={account} friend={friend} />);
xit('renders account static src as base of overlay avatar', () => {
expect(overlay.find('.account__avatar-overlay-base'))
.to.have.style('background-image', `url(${account.get('avatar_static')})`);
});
xit('renders friend static src as overlay of overlay avatar', () => {
expect(overlay.find('.account__avatar-overlay-overlay'))
.to.have.style('background-image', `url(${friend.get('avatar_static')})`);
});
});

View File

@ -1,72 +0,0 @@
import React from 'react';
import Button from '../../../app/javascript/mastodon/components/button';
import { expect } from 'chai';
import { shallow } from 'enzyme';
import sinon from 'sinon';
describe('<Button />', () => {
xit('renders a button element', () => {
const wrapper = shallow(<Button />);
expect(wrapper).to.match('button');
});
xit('renders the given text', () => {
const text = 'foo';
const wrapper = shallow(<Button text={text} />);
expect(wrapper.find('button')).to.have.text(text);
});
it('handles click events using the given handler', () => {
const handler = sinon.spy();
const wrapper = shallow(<Button onClick={handler} />);
wrapper.find('button').simulate('click');
expect(handler.calledOnce).to.equal(true);
});
it('does not handle click events if props.disabled given', () => {
const handler = sinon.spy();
const wrapper = shallow(<Button onClick={handler} disabled />);
wrapper.find('button').simulate('click');
expect(handler.called).to.equal(false);
});
xit('renders a disabled attribute if props.disabled given', () => {
const wrapper = shallow(<Button disabled />);
expect(wrapper.find('button')).to.be.disabled();
});
xit('renders the children', () => {
const children = <p>children</p>;
const wrapper = shallow(<Button>{children}</Button>);
expect(wrapper.find('button')).to.contain(children);
});
xit('renders the props.text instead of children', () => {
const text = 'foo';
const children = <p>children</p>;
const wrapper = shallow(<Button text={text}>{children}</Button>);
expect(wrapper.find('button')).to.have.text(text);
expect(wrapper.find('button')).to.not.contain(children);
});
xit('renders style="display: block; width: 100%;" if props.block given', () => {
const wrapper = shallow(<Button block />);
expect(wrapper.find('button')).to.have.className('button--block');
});
xit('renders style="display: inline-block; width: auto;" by default', () => {
const wrapper = shallow(<Button />);
expect(wrapper.find('button')).to.not.have.className('button--block');
});
xit('adds class "button-secondary" if props.secondary given', () => {
const wrapper = shallow(<Button secondary />);
expect(wrapper.find('button')).to.have.className('button-secondary');
});
xit('does not add class "button-secondary" by default', () => {
const wrapper = shallow(<Button />);
expect(wrapper.find('button')).to.not.have.className('button-secondary');
});
});

View File

@ -1,18 +0,0 @@
import React from 'react';
import DisplayName from '../../../app/javascript/mastodon/components/display_name';
import { expect } from 'chai';
import { render } from 'enzyme';
import { fromJS } from 'immutable';
describe('<DisplayName />', () => {
xit('renders display name + account name', () => {
const account = fromJS({
username: 'bar',
acct: 'bar@baz',
display_name_html: '<p>Foo</p>',
});
const wrapper = render(<DisplayName account={account} />);
expect(wrapper).to.have.text('Foo @bar@baz');
});
});

View File

@ -1,111 +0,0 @@
import { expect } from 'chai';
import { search } from '../../../app/javascript/mastodon/features/emoji/emoji_mart_search_light';
import { emojiIndex } from 'emoji-mart';
import { pick } from 'lodash';
const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
// hack to fix https://github.com/chaijs/type-detect/issues/98
// see: https://github.com/chaijs/type-detect/issues/98#issuecomment-325010785
import jsdom from 'jsdom';
global.window = new jsdom.JSDOM().window;
global.document = window.document;
global.HTMLElement = window.HTMLElement;
describe('emoji_index', () => {
it('should give same result for emoji_index_light and emoji-mart', () => {
let expected = [{
id: 'pineapple',
unified: '1f34d',
native: '🍍',
}];
expect(search('pineapple').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('pineapple').map(trimEmojis)).to.deep.equal(expected);
});
it('orders search results correctly', () => {
let expected = [{
id: 'apple',
unified: '1f34e',
native: '🍎',
}, {
id: 'pineapple',
unified: '1f34d',
native: '🍍',
}, {
id: 'green_apple',
unified: '1f34f',
native: '🍏',
}, {
id: 'iphone',
unified: '1f4f1',
native: '📱',
}];
expect(search('apple').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('apple').map(trimEmojis)).to.deep.equal(expected);
});
it('handles custom emoji', () => {
let custom = [{
id: 'mastodon',
name: 'mastodon',
short_names: ['mastodon'],
text: '',
emoticons: [],
keywords: ['mastodon'],
imageUrl: 'http://example.com',
custom: true,
}];
search('', { custom });
emojiIndex.search('', { custom });
let expected = [ { id: 'mastodon', custom: true } ];
expect(search('masto').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('masto').map(trimEmojis)).to.deep.equal(expected);
});
it('should filter only emojis we care about, exclude pineapple', () => {
let emojisToShowFilter = (unified) => unified !== '1F34D';
expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.to.contain('pineapple');
expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
.not.to.contain('pineapple');
});
it('can include/exclude categories', () => {
expect(search('flag', { include: ['people'] }))
.to.deep.equal([]);
expect(emojiIndex.search('flag', { include: ['people'] }))
.to.deep.equal([]);
});
it('does an emoji whose unified name is irregular', () => {
let expected = [{
'id': 'water_polo',
'unified': '1f93d',
'native': '🤽',
}, {
'id': 'man-playing-water-polo',
'unified': '1f93d-200d-2642-fe0f',
'native': '🤽‍♂️',
}, {
'id': 'woman-playing-water-polo',
'unified': '1f93d-200d-2640-fe0f',
'native': '🤽‍♀️',
}];
expect(search('polo').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('polo').map(trimEmojis)).to.deep.equal(expected);
});
it('can search for thinking_face', () => {
let expected = [ { id: 'thinking_face', unified: '1f914', native: '🤔' } ];
expect(search('thinking_fac').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('thinking_fac').map(trimEmojis)).to.deep.equal(expected);
});
it('can search for woman-facepalming', () => {
let expected = [ { id: 'woman-facepalming', unified: '1f926-200d-2640-fe0f', native: '🤦‍♀️' } ];
expect(search('woman-facep').map(trimEmojis)).to.deep.equal(expected);
expect(emojiIndex.search('woman-facep').map(trimEmojis)).deep.equal(expected);
});
});

View File

@ -1,61 +0,0 @@
import { expect } from 'chai';
import emojify from '../../../app/javascript/mastodon/features/emoji/emoji';
describe('emojify', () => {
it('ignores unknown shortcodes', () => {
expect(emojify(':foobarbazfake:')).to.equal(':foobarbazfake:');
});
it('ignores shortcodes inside of tags', () => {
expect(emojify('<p data-foo=":smile:"></p>')).to.equal('<p data-foo=":smile:"></p>');
});
it('works with unclosed tags', () => {
expect(emojify('hello>')).to.equal('hello>');
expect(emojify('<hello')).to.equal('<hello');
});
it('works with unclosed shortcodes', () => {
expect(emojify('smile:')).to.equal('smile:');
expect(emojify(':smile')).to.equal(':smile');
});
it('does unicode', () => {
expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal(
'<img draggable="false" class="emojione" alt="👩‍👩‍👦‍👦" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
expect(emojify('👨‍👩‍👧‍👧')).to.equal(
'<img draggable="false" class="emojione" alt="👨‍👩‍👧‍👧" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
expect(emojify('👩‍👩‍👦')).to.equal('<img draggable="false" class="emojione" alt="👩‍👩‍👦" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
expect(emojify('\u2757')).to.equal(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
});
it('does multiple unicode', () => {
expect(emojify('\u2757 #\uFE0F\u20E3')).to.equal(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757#\uFE0F\u20E3')).to.equal(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" />');
expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).to.equal(
'<img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" />');
expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).to.equal(
'foo <img draggable="false" class="emojione" alt="❗" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#️⃣" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
});
it('ignores unicode inside of tags', () => {
expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).to.equal('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
});
it('does multiple emoji properly (issue 5188)', () => {
expect(emojify('👌🌈💕')).to.equal('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
expect(emojify('👌 🌈 💕')).to.equal('<img draggable="false" class="emojione" alt="👌" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="🌈" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg" />');
});
it('does an emoji that has no shortcode', () => {
expect(emojify('🕉️')).to.equal('<img draggable="false" class="emojione" alt="🕉️" title="" src="/emoji/1f549.svg" />');
});
it('does an emoji whose filename is irregular', () => {
expect(emojify('↙️')).to.equal('<img draggable="false" class="emojione" alt="↙️" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
});
});

View File

@ -1,15 +0,0 @@
import { JSDOM } from 'jsdom';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
const { window } = new JSDOM('', {
userAgent: 'node.js',
});
Object.keys(window).forEach(property => {
if (typeof global[property] === 'undefined') {
global[property] = window[property];
}
});

View File

@ -231,33 +231,66 @@ RSpec.describe FeedManager do
end end
describe '#unpush' do describe '#unpush' do
it 'leaves a reblogged status when deleting the reblog' do let(:receiver) { Fabricate(:account) }
account = Fabricate(:account)
reblogged = Fabricate(:status)
status = Fabricate(:status, reblog: reblogged)
FeedManager.instance.push('type', account, status) it 'leaves a reblogged status if original was on feed' do
reblogged = Fabricate(:status)
status = Fabricate(:status, reblog: reblogged)
FeedManager.instance.push('type', receiver, reblogged)
FeedManager::REBLOG_FALLOFF.times { FeedManager.instance.push('type', receiver, Fabricate(:status)) }
FeedManager.instance.push('type', receiver, status)
# The reblogging status should show up under normal conditions. # The reblogging status should show up under normal conditions.
expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [status.id.to_s] expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to include(status.id.to_s)
FeedManager.instance.unpush('type', account, status) FeedManager.instance.unpush('type', receiver, status)
# Because we couldn't tell if the status showed up any other way, # Restore original status
# we had to stick the reblogged status in by itself. expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to_not include(status.id.to_s)
expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [reblogged.id.to_s] expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to include(reblogged.id.to_s)
end
it 'removes a reblogged status if it was only reblogged once' do
reblogged = Fabricate(:status)
status = Fabricate(:status, reblog: reblogged)
FeedManager.instance.push('type', receiver, status)
# The reblogging status should show up under normal conditions.
expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [status.id.to_s]
FeedManager.instance.unpush('type', receiver, status)
expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to be_empty
end
it 'leaves a reblogged status if another reblog was in feed' do
reblogged = Fabricate(:status)
status = Fabricate(:status, reblog: reblogged)
another_status = Fabricate(:status, reblog: reblogged)
FeedManager.instance.push('type', receiver, status)
FeedManager.instance.push('type', receiver, another_status)
# The reblogging status should show up under normal conditions.
expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [status.id.to_s]
FeedManager.instance.unpush('type', receiver, status)
expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [another_status.id.to_s]
end end
it 'sends push updates' do it 'sends push updates' do
account = Fabricate(:account) status = Fabricate(:status)
status = Fabricate(:status)
FeedManager.instance.push('type', account, status) FeedManager.instance.push('type', receiver, status)
allow(Redis.current).to receive_messages(publish: nil) allow(Redis.current).to receive_messages(publish: nil)
FeedManager.instance.unpush('type', account, status) FeedManager.instance.unpush('type', receiver, status)
deletion = Oj.dump(event: :delete, payload: status.id.to_s) deletion = Oj.dump(event: :delete, payload: status.id.to_s)
expect(Redis.current).to have_received(:publish).with("timeline:#{account.id}", deletion) expect(Redis.current).to have_received(:publish).with("timeline:#{receiver.id}", deletion)
end end
end end
end end

1023
yarn.lock

File diff suppressed because it is too large Load Diff