diff options
Diffstat (limited to 'app')
48 files changed, 751 insertions, 99 deletions
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 207c7b324..069026715 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, + :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, :setting_theme, diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js index 266a0020c..7856b26f9 100644 --- a/app/javascript/mastodon/base_polyfills.js +++ b/app/javascript/mastodon/base_polyfills.js @@ -1,5 +1,5 @@ import 'intl'; -import 'intl/locale-data/jsonp/en.js'; +import 'intl/locale-data/jsonp/en'; import 'es6-symbol/implement'; import includes from 'array-includes'; import assign from 'object-assign'; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap new file mode 100644 index 000000000..76ab3374a --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` +<div + className="account__avatar" + 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" + onMouseEnter={[Function]} + onMouseLeave={[Function]} + style={ + Object { + "backgroundImage": "url(/static/alice.jpg)", + "backgroundSize": "100px 100px", + "height": "100px", + "width": "100px", + } + } +/> +`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap new file mode 100644 index 000000000..d59fee42f --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap @@ -0,0 +1,24 @@ +// 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" + style={ + Object { + "backgroundImage": "url(/static/alice.jpg)", + } + } + /> + <div + className="account__avatar-overlay-overlay" + style={ + Object { + "backgroundImage": "url(/static/eve.jpg)", + } + } + /> +</div> +`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap new file mode 100644 index 000000000..c3f018d90 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap @@ -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> +`; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap new file mode 100644 index 000000000..533359ffe --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap @@ -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> +`; diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.js b/app/javascript/mastodon/components/__tests__/avatar-test.js new file mode 100644 index 000000000..dd3f7b7d2 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/avatar-test.js @@ -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 +}); diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js new file mode 100644 index 000000000..44addea83 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js @@ -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(); + }); +}); diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js new file mode 100644 index 000000000..160cd3cbc --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/button-test.js @@ -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(); + }); +}); diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.js b/app/javascript/mastodon/components/__tests__/display_name-test.js new file mode 100644 index 000000000..0d040c4cd --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/display_name-test.js @@ -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(); + }); +}); diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js index ad1453589..42ea37ec2 100644 --- a/app/javascript/mastodon/components/collapsable.js +++ b/app/javascript/mastodon/components/collapsable.js @@ -1,5 +1,5 @@ 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 PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 73ad46bb7..3a3ebf487 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import IconButton from './icon_button'; 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 detectPassiveEvents from 'detect-passive-events'; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 6fb191c6b..651b89566 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -1,7 +1,8 @@ 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 PropTypes from 'prop-types'; +import classNames from 'classnames'; export default class IconButton extends React.PureComponent { @@ -56,27 +57,26 @@ export default class IconButton extends React.PureComponent { style.textAlign = 'left'; } - const classes = ['icon-button']; - - if (this.props.active) { - classes.push('active'); - } - - if (this.props.disabled) { - 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 { + active, + animate, + className, + disabled, + expanded, + icon, + inverted, + overlay, + pressed, + tabIndex, + title, + } = this.props; + + const classes = classNames(className, 'icon-button', { + active, + disabled, + inverted, + overlayed: overlay, + }); const flipDeg = this.props.flip ? -180 : -360; const rotateDeg = this.props.active ? flipDeg : 0; @@ -94,19 +94,19 @@ export default class IconButton extends React.PureComponent { }; return ( - <Motion defaultStyle={motionDefaultStyle} style={motionStyle}> + <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> {({ rotate }) => <button - aria-label={this.props.title} - aria-pressed={this.props.pressed} - aria-expanded={this.props.expanded} - title={this.props.title} - className={classes.join(' ')} + aria-label={title} + aria-pressed={pressed} + aria-expanded={expanded} + title={title} + className={classes} onClick={this.handleClick} 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} </button> } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index cf9c8fb53..af152cc32 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -16,6 +16,7 @@ const messages = defineMessages({ block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, share: { id: 'status.share', defaultMessage: 'Share' }, + more: { id: 'status.more', defaultMessage: 'More' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, @@ -182,7 +183,7 @@ export default class StatusActionBar extends ImmutablePureComponent { {shareButton} <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> ); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 6beffca1c..a7138e62d 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -10,6 +10,7 @@ import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; + const { localeData, messages } = getLocale(); addLocaleData(localeData); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 5402d6753..57678d162 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; 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 { connect } from 'react-redux'; import ImmutablePureComponent from 'react-immutable-pure-component'; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index e38ed38c1..c1e85aee3 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { injectIntl, defineMessages } from 'react-intl'; import IconButton from '../../../components/icon_button'; 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 detectPassiveEvents from 'detect-passive-events'; import classNames from 'classnames'; diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index f57d54618..398fc44ce 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; 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'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index cd9e08360..5d8d66cf7 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; 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 ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl } from 'react-intl'; diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js index 3e49098c7..d5e6f19cd 100644 --- a/app/javascript/mastodon/features/compose/components/upload_progress.js +++ b/app/javascript/mastodon/features/compose/components/upload_progress.js @@ -1,6 +1,6 @@ import React from 'react'; 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 { FormattedMessage } from 'react-intl'; diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js index a0814e984..803b7f86a 100644 --- a/app/javascript/mastodon/features/compose/components/warning.js +++ b/app/javascript/mastodon/features/compose/components/warning.js @@ -1,6 +1,6 @@ import React from 'react'; 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'; export default class Warning extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js deleted file mode 100644 index a9e3a9edf..000000000 --- a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js +++ /dev/null @@ -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); diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index 8624849f3..e4bd5a743 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import IconButton from '../../../components/icon_button'; 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 { injectIntl, defineMessages } from 'react-intl'; diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 9068648bd..41a97d550 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -10,7 +10,7 @@ import { changeLocalSetting } from '../../../glitch/actions/local_settings'; import { Link } from 'react-router-dom'; import { injectIntl, defineMessages } from 'react-intl'; 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 SearchResultsContainer from './containers/search_results_container'; import { changeComposing } from '../../actions/compose'; diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js new file mode 100644 index 000000000..636402172 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -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" />'); + }); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js new file mode 100644 index 000000000..53efa5743 --- /dev/null +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js @@ -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); + }); +}); diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js index 3bd89cf3b..c0cba952a 100644 --- a/app/javascript/mastodon/features/emoji/emoji_compressed.js +++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js @@ -9,7 +9,8 @@ const { unicodeToFilename } = require('./unicode_to_filename'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); const emojiMap = require('./emoji_map.json'); const { emojiIndex } = require('emoji-mart'); -const emojiMartData = require('emoji-mart/dist/data').default; +const { default: emojiMartData } = require('emoji-mart/dist/data'); + const excluded = ['ยฎ', 'ยฉ', 'โข']; const skins = ['๐ป', '๐ผ', '๐ฝ', '๐พ', '๐ฟ']; const shortcodeMap = {}; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 816f83e45..d8547db36 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -48,6 +48,8 @@ export default class DetailedStatus extends ImmutablePureComponent { let media = ''; let mediaIcon = null; let applicationLink = ''; + let reblogLink = ''; + let reblogIcon = 'retweet'; if (status.get('media_attachments').size > 0) { 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>; } + 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 ( <div className='detailed-status'> <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'> <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' /> - </a>{applicationLink} ยท <Link to={`/statuses/${status.get('id')}/reblogs`} 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'> + </a>{applicationLink} ยท {reblogLink} ยท <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> <i className='fa fa-star' /> <span className='detailed-status__favorites'> <FormattedNumber value={status.get('favourites_count')} /> diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js new file mode 100644 index 000000000..1e5e1d8dc --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Column from '../column'; +import ColumnHeader from '../column_header'; + +describe('<Column />', () => { + describe('<ColumnHeader /> click handler', () => { + const originalRaf = global.requestAnimationFrame; + + beforeEach(() => { + global.requestAnimationFrame = jest.fn(); + }); + + afterAll(() => { + global.requestAnimationFrame = originalRaf; + }); + + it('runs the scroll animation if the column contains scrollable content', () => { + const wrapper = mount( + <Column heading='notifications'> + <div className='scrollable' /> + </Column> + ); + wrapper.find(ColumnHeader).simulate('click'); + expect(global.requestAnimationFrame.mock.calls.length).toEqual(1); + }); + + it('does not try to scroll if there is no scrollable content', () => { + const wrapper = mount(<Column heading='notifications' />); + wrapper.find(ColumnHeader).simulate('click'); + expect(global.requestAnimationFrame.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js index dda28feeb..c19065be6 100644 --- a/app/javascript/mastodon/features/ui/components/upload_area.js +++ b/app/javascript/mastodon/features/ui/components/upload_area.js @@ -1,6 +1,6 @@ import React from 'react'; 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 { FormattedMessage } from 'react-intl'; diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js new file mode 100644 index 000000000..4276eeaa4 --- /dev/null +++ b/app/javascript/mastodon/features/ui/util/optional_motion.js @@ -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); diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index fe2433591..1130e6c09 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -184,6 +184,7 @@ "status.load_more": "Carrega mรฉs", "status.media_hidden": "Multimรจdia amagat", "status.mention": "Esmentar @{name}", + "status.more": "Mรฉs", "status.mute_conversation": "Silenciar conversaciรณ", "status.open": "Ampliar aquest estat", "status.pin": "Fixat en el perfil", diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 12efe0e0c..7e8d30c64 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -179,6 +179,7 @@ "status.load_more": "Load more", "status.media_hidden": "Media hidden", "status.mention": "Mention @{name}", + "status.more": "More", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", "status.pin": "Pin on profile", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index f6bfbb04d..03dd9ce02 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -179,6 +179,7 @@ "status.load_more": "Cargar mรกs", "status.media_hidden": "Contenido multimedia oculto", "status.mention": "Mencionar", + "status.more": "Mรกs", "status.mute_conversation": "Silenciar conversaciรณn", "status.open": "Expandir estado", "status.pin": "Fijar", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 145b683f3..f192f3cfc 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -179,6 +179,7 @@ "status.load_more": "Charger plus", "status.media_hidden": "Mรฉdia cachรฉ", "status.mention": "Mentionner", + "status.more": "Plus", "status.mute_conversation": "Masquer la conversation", "status.open": "Dรฉplier ce statut", "status.pin": "รpingler sur le profil", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index c1768cf8f..637acfab6 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -18,7 +18,7 @@ "account.unblock_domain": "{domain} ์จ๊น ํด์ ", "account.unfollow": "ํ๋ก์ฐ ํด์ ", "account.unmute": "๋ฎคํธ ํด์ ", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "์ ์ฒด ํ๋กํ ๋ณด๊ธฐ", "boost_modal.combo": "๋ค์๋ถํฐ {combo}๋ฅผ ๋๋ฅด๋ฉด ์ด ๊ณผ์ ์ ๊ฑด๋๋ธ ์ ์์ต๋๋ค.", "bundle_column_error.body": "Something went wrong while loading this component.", "bundle_column_error.retry": "Try again", @@ -33,7 +33,7 @@ "column.home": "ํ", "column.mutes": "๋ฎคํธ ์ค์ธ ์ฌ์ฉ์", "column.notifications": "์๋ฆผ", - "column.pins": "๊ณ ์ ๋ Toot", + "column.pins": "๊ณ ์ ๋ ํฟ", "column.public": "์ฐํฉ ํ์๋ผ์ธ", "column_back_button.label": "๋์๊ฐ๊ธฐ", "column_header.hide_settings": "Hide settings", @@ -47,7 +47,7 @@ "compose_form.lock_disclaimer": "์ด ๊ณ์ ์ {locked}๋ก ์ค์ ๋์ด ์์ง ์์ต๋๋ค. ๋๊ตฌ๋ ์ด ๊ณ์ ์ ํ๋ก์ฐ ํ ์ ์์ผ๋ฉฐ, ํ๋ก์ ๊ณต๊ฐ์ ํฌ์คํ ์ ๋ณผ ์ ์์ต๋๋ค.", "compose_form.lock_disclaimer.lock": "๋น๊ณต๊ฐ", "compose_form.placeholder": "์ง๊ธ ๋ฌด์์ ํ๊ณ ์๋์?", - "compose_form.publish": "Toot", + "compose_form.publish": "ํฟ", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "์ด ๋ฏธ๋์ด๋ฅผ ๋ฏผ๊ฐํ ๋ฏธ๋์ด๋ก ์ทจ๊ธ", "compose_form.spoiler": "ํ ์คํธ ์จ๊ธฐ๊ธฐ", @@ -63,8 +63,8 @@ "confirmations.mute.message": "์ ๋ง๋ก {name}๋ฅผ ๋ฎคํธํ์๊ฒ ์ต๋๊น?", "confirmations.unfollow.confirm": "Unfollow", "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.preview": "Here is what it will look like:", + "embed.instructions": "์๋์ ์ฝ๋๋ฅผ ๋ณต์ฌํ์ฌ ๋ํ๋ฅผ ์ํ๋ ๊ณณ์ผ๋ก ํผ๊ฐ์ธ์.", + "embed.preview": "๋ค์๊ณผ ๊ฐ์ด ํ์๋ฉ๋๋ค:", "emoji_button.activity": "ํ๋", "emoji_button.custom": "Custom", "emoji_button.flags": "๊ตญ๊ธฐ", @@ -82,7 +82,6 @@ "empty_column.community": "๋ก์ปฌ ํ์๋ผ์ธ์ ์๋ฌด ๊ฒ๋ ์์ต๋๋ค. ์๋ฌด๊ฑฐ๋ ์ ์ด ๋ณด์ธ์!", "empty_column.hashtag": "์ด ํด์ํ๊ทธ๋ ์์ง ์ฌ์ฉ๋์ง ์์์ต๋๋ค.", "empty_column.home": "์์ง ์๋ฌด๋ ํ๋ก์ฐ ํ๊ณ ์์ง ์์ต๋๋ค. {public}๋ฅผ ๋ณด๋ฌ ๊ฐ๊ฑฐ๋, ๊ฒ์ํ์ฌ ๋ค๋ฅธ ์ฌ์ฉ์๋ฅผ ์ฐพ์ ๋ณด์ธ์.", - "empty_column.home.inactivity": "ํ ํผ๋์ ์๋ฌด ๊ฒ๋ ์์ต๋๋ค. ํ๋์ ํ๋ํ์ง ์์ ๊ฒฝ์ฐ ๊ณง ์๋๋๋ก ๋์์ฌ ๊ฒ์ ๋๋ค.", "empty_column.home.public_timeline": "์ฐํฉ ํ์๋ผ์ธ", "empty_column.notifications": "์์ง ์๋ฆผ์ด ์์ต๋๋ค. ๋ค๋ฅธ ์ฌ๋๊ณผ ๋ํ๋ฅผ ์์ํด ๋ณด์ธ์!", "empty_column.public": "์ฌ๊ธฐ์ ์์ง ์๋ฌด ๊ฒ๋ ์์ต๋๋ค! ๊ณต๊ฐ์ ์ผ๋ก ๋ฌด์ธ๊ฐ ํฌ์คํ ํ๊ฑฐ๋, ๋ค๋ฅธ ์ธ์คํด์ค ์ ์ ๋ฅผ ํ๋ก์ฐ ํด์ ๊ฐ๋ ์ฑ์๋ณด์ธ์!", @@ -113,7 +112,7 @@ "navigation_bar.info": "์ด ์ธ์คํด์ค์ ๋ํด์", "navigation_bar.logout": "๋ก๊ทธ์์", "navigation_bar.mutes": "๋ฎคํธ ์ค์ธ ์ฌ์ฉ์", - "navigation_bar.pins": "๊ณ ์ ๋ Toot", + "navigation_bar.pins": "๊ณ ์ ๋ ํฟ", "navigation_bar.preferences": "์ฌ์ฉ์ ์ค์ ", "navigation_bar.public_timeline": "์ฐํฉ ํ์๋ผ์ธ", "notification.favourite": "{name}๋์ด ์ฆ๊ฒจ์ฐพ๊ธฐ ํ์ต๋๋ค", @@ -159,29 +158,34 @@ "privacy.public.long": "๊ณต๊ฐ ํ์๋ผ์ธ์ ํ์", "privacy.public.short": "๊ณต๊ฐ", "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": "์ทจ์", "report.placeholder": "์ฝ๋ฉํธ", "report.submit": "์ ๊ณ ํ๊ธฐ", "report.target": "๋ฌธ์ ๊ฐ ๋ ์ฌ์ฉ์", "search.placeholder": "๊ฒ์", - "search_popout.search_format": "Advanced search format", - "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", + "search_popout.search_format": "๊ณ ๊ธ ๊ฒ์ ๋ฐฉ๋ฒ", + "search_popout.tips.hashtag": "ํด์ํ๊ทธ", + "search_popout.tips.status": "ํฟ", + "search_popout.tips.text": "๋จ์ํ ํ ์คํธ ๊ฒ์์ ๊ด๊ณ๋ ํ๋กํ ์ด๋ฆ, ์ ์ ์ด๋ฆ ๊ทธ๋ฆฌ๊ณ ํด์ํ๊ทธ๋ฅผ ํ์ํฉ๋๋ค", + "search_popout.tips.user": "์ ์ ", "search_results.total": "{count, number}๊ฑด์ ๊ฒฐ๊ณผ", "standalone.public_title": "A look inside...", "status.cannot_reblog": "์ด ํฌ์คํธ๋ ๋ถ์คํธ ํ ์ ์์ต๋๋ค", "status.delete": "์ญ์ ", - "status.embed": "Embed", + "status.embed": "๊ณต์ ํ๊ธฐ", "status.favourite": "์ฆ๊ฒจ์ฐพ๊ธฐ", "status.load_more": "๋ ๋ณด๊ธฐ", "status.media_hidden": "๋ฏธ๋์ด ์จ๊ฒจ์ง", "status.mention": "๋ต์ฅ", "status.mute_conversation": "์ด ๋ํ๋ฅผ ๋ฎคํธ", "status.open": "์์ธ ์ ๋ณด ํ์", - "status.pin": "Pin on profile", + "status.pin": "๊ณ ์ ", "status.reblog": "๋ถ์คํธ", "status.reblogged_by": "{name}๋์ด ๋ถ์คํธ ํ์ต๋๋ค", "status.reply": "๋ต์ฅ", @@ -193,7 +197,7 @@ "status.show_less": "์จ๊ธฐ๊ธฐ", "status.show_more": "๋ ๋ณด๊ธฐ", "status.unmute_conversation": "์ด ๋ํ์ ๋ฎคํธ ํด์ ํ๊ธฐ", - "status.unpin": "Unpin from profile", + "status.unpin": "๊ณ ์ ํด์ ", "tabs_bar.compose": "ํฌ์คํธ", "tabs_bar.federated_timeline": "์ฐํฉ", "tabs_bar.home": "ํ", @@ -212,5 +216,9 @@ "video.mute": "Mute sound", "video.pause": "Pause", "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" } diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 4715f60ef..773f2d1e4 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -179,6 +179,7 @@ "status.load_more": "Cargar mai", "status.media_hidden": "Mรจdia rescondut", "status.mention": "Mencionar", + "status.more": "Mai", "status.mute_conversation": "Rescondre la conversacion", "status.open": "Desplegar aqueste estatut", "status.pin": "Penjar al perfil", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index c8228c0cb..77da77e10 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -179,6 +179,7 @@ "status.load_more": "Zaลaduj wiฤcej", "status.media_hidden": "Zawartoลฤ multimedialna ukryta", "status.mention": "Wspomnij o @{name}", + "status.more": "Wiฤcej", "status.mute_conversation": "Wycisz konwersacjฤ", "status.open": "Rozszerz ten wpis", "status.pin": "Przypnij do profilu", diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index c85cd5800..93d2eaf10 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -1,6 +1,5 @@ -import * as OfflinePluginRuntime from 'offline-plugin/runtime'; import * as WebPushSubscription from './web_push_subscription'; -import Mastodon from 'mastodon/containers/mastodon'; +import Mastodon from './containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; import ready from './ready'; @@ -25,7 +24,7 @@ function main() { ReactDOM.render(<Mastodon {...props} />, mountNode); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug - OfflinePluginRuntime.install(); + require('offline-plugin/runtime').install(); WebPushSubscription.register(); } perf.stop('main()'); diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index b17d74ef3..c3f117647 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -31,10 +31,10 @@ const initialTimeline = ImmutableMap({ }); 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 hadNext = state.getIn([timeline, 'next']); - const oldIds = state.getIn([timeline, 'items'], ImmutableList()); return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('loaded', true); @@ -45,8 +45,8 @@ const normalizeTimeline = (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 ids = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId)); return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js new file mode 100644 index 000000000..80148379b --- /dev/null +++ b/app/javascript/mastodon/test_setup.js @@ -0,0 +1,5 @@ +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +const adapter = new Adapter(); +configure({ adapter }); diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js index de0c68fa5..cb47514d3 100644 --- a/app/javascript/packs/common.js +++ b/app/javascript/packs/common.js @@ -1,6 +1,8 @@ import { start } from 'rails-ujs'; +import 'font-awesome/css/font-awesome.css'; // import common styling require('../styles/common.scss'); +require.context('../images/', true); start(); diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 5a3af7206..1bbaad4cc 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -204,10 +204,11 @@ class FeedManager # 2. Remove the reblogged status from the `:reblogs` zset. redis.zrem(reblog_key, status.reblog_of_id) - # 3. Add the reblogged status to the feed using the reblogging - # status' ID as its score, and the reblogged status' ID as its - # value. - redis.zadd(timeline_key, status.id, status.reblog_of_id) + # 3. Add the reblogged status to the feed. + # Note that we can't use old score in here + # and it must be an ID of corresponding status + # because we need to filter timeline by status ID. + redis.zadd(timeline_key, status.reblog_of_id, status.reblog_of_id) # 4. Remove the reblogging status from the feed (as normal) end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 3b7a856ee..d86959c0b 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -23,6 +23,7 @@ class UserSettingsDecorator 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['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['noindex'] = noindex_preference if change?('setting_noindex') user.settings['theme'] = theme_preference if change?('setting_theme') @@ -64,6 +65,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_auto_play_gif' end + def reduce_motion_preference + boolean_cast_setting 'setting_reduce_motion' + end + def noindex_preference boolean_cast_setting 'setting_noindex' end diff --git a/app/models/user.rb b/app/models/user.rb index 3bf069a31..325e27f44 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -102,6 +102,10 @@ class User < ApplicationRecord settings.auto_play_gif end + def setting_reduce_motion + settings.reduce_motion + end + def setting_system_font_ui settings.system_font_ui end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 0992771fc..1f5ee789a 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -25,6 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:boost_modal] = object.current_account.user.setting_boost_modal store[:delete_modal] = object.current_account.user.setting_delete_modal store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif + store[:reduce_motion] = object.current_account.user.setting_reduce_motion end store diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 7475e3fd2..69e26a7be 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -35,6 +35,7 @@ .fields-group = 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 .actions diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index a4eab16df..b488bd9ba 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -19,15 +19,14 @@ %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'}" }< = Formatter.instance.format(status, custom_emojify: true) - - if !status.media_attachments.empty? - if status.media_attachments.first.video? - 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 - %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 - %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 %data.dt-published{ value: status.created_at.to_time.iso8601 } @@ -40,9 +39,16 @@ - else = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener' ยท - %span< - = fa_icon('retweet') - %span= status.reblogs_count + - if status.direct_visibility? + %span< + = fa_icon('envelope') + - elsif status.private_visibility? + %span< + = fa_icon('lock') + - else + %span< + = fa_icon('retweet') + %span= status.reblogs_count ยท %span< = fa_icon('star') |