From 45c44989c8fb6e24badd18bb83ac5f68de0aceaf Mon Sep 17 00:00:00 2001
From: kibigo!
Date: Fri, 17 Nov 2017 19:11:18 -0800
Subject: Forking glitch theme
---
.../__tests__/__snapshots__/avatar-test.js.snap | 35 ++
.../__snapshots__/avatar_overlay-test.js.snap | 26 ++
.../__tests__/__snapshots__/button-test.js.snap | 130 ++++++
.../__snapshots__/display_name-test.js.snap | 23 ++
.../glitch/components/__tests__/avatar-test.js | 36 ++
.../components/__tests__/avatar_overlay-test.js | 29 ++
.../glitch/components/__tests__/button-test.js | 82 ++++
.../components/__tests__/display_name-test.js | 18 +
app/javascript/themes/glitch/components/account.js | 116 ++++++
.../themes/glitch/components/attachment_list.js | 33 ++
.../themes/glitch/components/autosuggest_emoji.js | 42 ++
.../glitch/components/autosuggest_textarea.js | 222 +++++++++++
app/javascript/themes/glitch/components/avatar.js | 72 ++++
.../themes/glitch/components/avatar_overlay.js | 30 ++
app/javascript/themes/glitch/components/button.js | 64 +++
.../themes/glitch/components/collapsable.js | 22 ++
app/javascript/themes/glitch/components/column.js | 54 +++
.../themes/glitch/components/column_back_button.js | 29 ++
.../glitch/components/column_back_button_slim.js | 31 ++
.../themes/glitch/components/column_header.js | 214 ++++++++++
.../themes/glitch/components/display_name.js | 20 +
.../themes/glitch/components/dropdown_menu.js | 211 ++++++++++
.../glitch/components/extended_video_player.js | 54 +++
.../themes/glitch/components/icon_button.js | 137 +++++++
.../components/intersection_observer_article.js | 130 ++++++
.../themes/glitch/components/load_more.js | 26 ++
.../themes/glitch/components/loading_indicator.js | 11 +
.../themes/glitch/components/media_gallery.js | 256 ++++++++++++
.../themes/glitch/components/missing_indicator.js | 12 +
.../components/notification_purge_buttons.js | 58 +++
.../themes/glitch/components/permalink.js | 34 ++
.../themes/glitch/components/relative_timestamp.js | 147 +++++++
.../themes/glitch/components/scrollable_list.js | 198 ++++++++++
.../themes/glitch/components/setting_text.js | 34 ++
app/javascript/themes/glitch/components/status.js | 436 +++++++++++++++++++++
.../themes/glitch/components/status_action_bar.js | 188 +++++++++
.../themes/glitch/components/status_content.js | 245 ++++++++++++
.../themes/glitch/components/status_header.js | 120 ++++++
.../themes/glitch/components/status_list.js | 72 ++++
.../themes/glitch/components/status_prepend.js | 83 ++++
.../glitch/components/status_visibility_icon.js | 48 +++
41 files changed, 3828 insertions(+)
create mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
create mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
create mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
create mode 100644 app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
create mode 100644 app/javascript/themes/glitch/components/__tests__/avatar-test.js
create mode 100644 app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
create mode 100644 app/javascript/themes/glitch/components/__tests__/button-test.js
create mode 100644 app/javascript/themes/glitch/components/__tests__/display_name-test.js
create mode 100644 app/javascript/themes/glitch/components/account.js
create mode 100644 app/javascript/themes/glitch/components/attachment_list.js
create mode 100644 app/javascript/themes/glitch/components/autosuggest_emoji.js
create mode 100644 app/javascript/themes/glitch/components/autosuggest_textarea.js
create mode 100644 app/javascript/themes/glitch/components/avatar.js
create mode 100644 app/javascript/themes/glitch/components/avatar_overlay.js
create mode 100644 app/javascript/themes/glitch/components/button.js
create mode 100644 app/javascript/themes/glitch/components/collapsable.js
create mode 100644 app/javascript/themes/glitch/components/column.js
create mode 100644 app/javascript/themes/glitch/components/column_back_button.js
create mode 100644 app/javascript/themes/glitch/components/column_back_button_slim.js
create mode 100644 app/javascript/themes/glitch/components/column_header.js
create mode 100644 app/javascript/themes/glitch/components/display_name.js
create mode 100644 app/javascript/themes/glitch/components/dropdown_menu.js
create mode 100644 app/javascript/themes/glitch/components/extended_video_player.js
create mode 100644 app/javascript/themes/glitch/components/icon_button.js
create mode 100644 app/javascript/themes/glitch/components/intersection_observer_article.js
create mode 100644 app/javascript/themes/glitch/components/load_more.js
create mode 100644 app/javascript/themes/glitch/components/loading_indicator.js
create mode 100644 app/javascript/themes/glitch/components/media_gallery.js
create mode 100644 app/javascript/themes/glitch/components/missing_indicator.js
create mode 100644 app/javascript/themes/glitch/components/notification_purge_buttons.js
create mode 100644 app/javascript/themes/glitch/components/permalink.js
create mode 100644 app/javascript/themes/glitch/components/relative_timestamp.js
create mode 100644 app/javascript/themes/glitch/components/scrollable_list.js
create mode 100644 app/javascript/themes/glitch/components/setting_text.js
create mode 100644 app/javascript/themes/glitch/components/status.js
create mode 100644 app/javascript/themes/glitch/components/status_action_bar.js
create mode 100644 app/javascript/themes/glitch/components/status_content.js
create mode 100644 app/javascript/themes/glitch/components/status_header.js
create mode 100644 app/javascript/themes/glitch/components/status_list.js
create mode 100644 app/javascript/themes/glitch/components/status_prepend.js
create mode 100644 app/javascript/themes/glitch/components/status_visibility_icon.js
(limited to 'app/javascript/themes/glitch/components')
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
new file mode 100644
index 000000000..4005c860f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar-test.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Autoplay renders a animated avatar 1`] = `
+
+`;
+
+exports[` Still renders a still avatar 1`] = `
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
new file mode 100644
index 000000000..d9e5e5252
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
+
+
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
new file mode 100644
index 000000000..707cbf673
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/button-test.js.snap
@@ -0,0 +1,130 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` adds class "button-secondary" if props.secondary given 1`] = `
+
+`;
+
+exports[` renders a button element 1`] = `
+
+`;
+
+exports[` renders a disabled attribute if props.disabled given 1`] = `
+
+`;
+
+exports[` renders class="button--block" if props.block given 1`] = `
+
+`;
+
+exports[` renders the children 1`] = `
+
+
+ children
+
+
+`;
+
+exports[` renders the given text 1`] = `
+
+ foo
+
+`;
+
+exports[` renders the props.text instead of children 1`] = `
+
+ foo
+
+`;
+
+exports[` renders title if props.title is given 1`] = `
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
new file mode 100644
index 000000000..533359ffe
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/__snapshots__/display_name-test.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders display name + account name 1`] = `
+
+ Foo
",
+ }
+ }
+ />
+
+
+ @
+ bar@baz
+
+
+`;
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar-test.js b/app/javascript/themes/glitch/components/__tests__/avatar-test.js
new file mode 100644
index 000000000..dd3f7b7d2
--- /dev/null
+++ b/app/javascript/themes/glitch/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(' ', () => {
+ 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( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ describe('Still', () => {
+ it('renders a still avatar', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ // TODO add autoplay test if possible
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js b/app/javascript/themes/glitch/components/__tests__/avatar_overlay-test.js
new file mode 100644
index 000000000..44addea83
--- /dev/null
+++ b/app/javascript/themes/glitch/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(' {
+ 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( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/button-test.js b/app/javascript/themes/glitch/components/__tests__/button-test.js
new file mode 100644
index 000000000..924ba39dc
--- /dev/null
+++ b/app/javascript/themes/glitch/components/__tests__/button-test.js
@@ -0,0 +1,82 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Button from '../button';
+
+describe(' ', () => {
+ it('renders a button element', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the given text', () => {
+ const text = 'foo';
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('handles click events using the given handler', () => {
+ const handler = jest.fn();
+ const button = shallow( );
+ 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.find('button').simulate('click');
+
+ expect(handler.mock.calls.length).toEqual(0);
+ });
+
+ it('renders a disabled attribute if props.disabled given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the children', () => {
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders the props.text instead of children', () => {
+ const text = 'foo';
+ const children = children
;
+ const component = renderer.create({children} );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders class="button--block" if props.block given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('adds class "button-secondary" if props.secondary given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+
+ it('renders title if props.title is given', () => {
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/__tests__/display_name-test.js b/app/javascript/themes/glitch/components/__tests__/display_name-test.js
new file mode 100644
index 000000000..0d040c4cd
--- /dev/null
+++ b/app/javascript/themes/glitch/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(' ', () => {
+ it('renders display name + account name', () => {
+ const account = fromJS({
+ username: 'bar',
+ acct: 'bar@baz',
+ display_name_html: 'Foo
',
+ });
+ const component = renderer.create( );
+ const tree = component.toJSON();
+
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/app/javascript/themes/glitch/components/account.js b/app/javascript/themes/glitch/components/account.js
new file mode 100644
index 000000000..d0ff77050
--- /dev/null
+++ b/app/javascript/themes/glitch/components/account.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+ mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
+ unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
+});
+
+@injectIntl
+export default class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ onBlock: PropTypes.func.isRequired,
+ onMute: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ hidden: PropTypes.bool,
+ };
+
+ handleFollow = () => {
+ this.props.onFollow(this.props.account);
+ }
+
+ handleBlock = () => {
+ this.props.onBlock(this.props.account);
+ }
+
+ handleMute = () => {
+ this.props.onMute(this.props.account);
+ }
+
+ handleMuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, true);
+ }
+
+ handleUnmuteNotifications = () => {
+ this.props.onMuteNotifications(this.props.account, false);
+ }
+
+ render () {
+ const { account, intl, hidden } = this.props;
+
+ if (!account) {
+ return
;
+ }
+
+ if (hidden) {
+ return (
+
+ {account.get('display_name')}
+ {account.get('username')}
+
+ );
+ }
+
+ let buttons;
+
+ if (account.get('id') !== me && account.get('relationship', null) !== null) {
+ const following = account.getIn(['relationship', 'following']);
+ const requested = account.getIn(['relationship', 'requested']);
+ const blocking = account.getIn(['relationship', 'blocking']);
+ const muting = account.getIn(['relationship', 'muting']);
+
+ if (requested) {
+ buttons = ;
+ } else if (blocking) {
+ buttons = ;
+ } else if (muting) {
+ let hidingNotificationsButton;
+ if (muting.get('notifications')) {
+ hidingNotificationsButton = ;
+ } else {
+ hidingNotificationsButton = ;
+ }
+ buttons = (
+
+
+ {hidingNotificationsButton}
+
+ );
+ } else {
+ buttons = ;
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {buttons}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/attachment_list.js b/app/javascript/themes/glitch/components/attachment_list.js
new file mode 100644
index 000000000..b3d00b335
--- /dev/null
+++ b/app/javascript/themes/glitch/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+export default class AttachmentList extends ImmutablePureComponent {
+
+ static propTypes = {
+ media: ImmutablePropTypes.list.isRequired,
+ };
+
+ render () {
+ const { media } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/autosuggest_emoji.js b/app/javascript/themes/glitch/components/autosuggest_emoji.js
new file mode 100644
index 000000000..3c6f915e4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/autosuggest_emoji.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from 'themes/glitch/util/emoji/emoji_unicode_mapping_light';
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.object.isRequired,
+ };
+
+ render () {
+ const { emoji } = this.props;
+ let url;
+
+ if (emoji.custom) {
+ url = emoji.imageUrl;
+ } else {
+ const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+ if (!mapping) {
+ return null;
+ }
+
+ url = `${assetHost}/emoji/${mapping.filename}.svg`;
+ }
+
+ return (
+
+
+
+ {emoji.colons}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/autosuggest_textarea.js b/app/javascript/themes/glitch/components/autosuggest_textarea.js
new file mode 100644
index 000000000..fa93847a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/autosuggest_textarea.js
@@ -0,0 +1,222 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'themes/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'themes/glitch/util/rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+ let word;
+
+ let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+ let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+ if (right < 0) {
+ word = str.slice(left);
+ } else {
+ word = str.slice(left, right + caretPosition);
+ }
+
+ if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
+ return [null, null];
+ }
+
+ word = word.trim().toLowerCase();
+
+ if (word.length > 0) {
+ return [left + 1, word];
+ } else {
+ return [null, null];
+ }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+ static propTypes = {
+ value: PropTypes.string,
+ suggestions: ImmutablePropTypes.list,
+ disabled: PropTypes.bool,
+ placeholder: PropTypes.string,
+ onSuggestionSelected: PropTypes.func.isRequired,
+ onSuggestionsClearRequested: PropTypes.func.isRequired,
+ onSuggestionsFetchRequested: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onKeyUp: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onPaste: PropTypes.func.isRequired,
+ autoFocus: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoFocus: true,
+ };
+
+ state = {
+ suggestionsHidden: false,
+ selectedSuggestion: 0,
+ lastToken: null,
+ tokenStart: 0,
+ };
+
+ onChange = (e) => {
+ const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+ if (token !== null && this.state.lastToken !== token) {
+ this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+ this.props.onSuggestionsFetchRequested(token);
+ } else if (token === null) {
+ this.setState({ lastToken: null });
+ this.props.onSuggestionsClearRequested();
+ }
+
+ this.props.onChange(e);
+ }
+
+ onKeyDown = (e) => {
+ const { suggestions, disabled } = this.props;
+ const { selectedSuggestion, suggestionsHidden } = this.state;
+
+ if (disabled) {
+ e.preventDefault();
+ return;
+ }
+
+ switch(e.key) {
+ case 'Escape':
+ if (!suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ suggestionsHidden: true });
+ }
+
+ break;
+ case 'ArrowDown':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+ }
+
+ break;
+ case 'ArrowUp':
+ if (suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+ }
+
+ break;
+ case 'Enter':
+ case 'Tab':
+ // Select suggestion
+ if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+ }
+
+ break;
+ }
+
+ if (e.defaultPrevented || !this.props.onKeyDown) {
+ return;
+ }
+
+ this.props.onKeyDown(e);
+ }
+
+ onKeyUp = e => {
+ if (e.key === 'Escape' && this.state.suggestionsHidden) {
+ document.querySelector('.ui').parentElement.focus();
+ }
+
+ if (this.props.onKeyUp) {
+ this.props.onKeyUp(e);
+ }
+ }
+
+ onBlur = () => {
+ this.setState({ suggestionsHidden: true });
+ }
+
+ onSuggestionClick = (e) => {
+ const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+ e.preventDefault();
+ this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+ this.textarea.focus();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+ this.setState({ suggestionsHidden: false });
+ }
+ }
+
+ setTextarea = (c) => {
+ this.textarea = c;
+ }
+
+ onPaste = (e) => {
+ if (e.clipboardData && e.clipboardData.files.length === 1) {
+ this.props.onPaste(e.clipboardData.files);
+ e.preventDefault();
+ }
+ }
+
+ renderSuggestion = (suggestion, i) => {
+ const { selectedSuggestion } = this.state;
+ let inner, key;
+
+ if (typeof suggestion === 'object') {
+ inner = ;
+ key = suggestion.id;
+ } else {
+ inner = ;
+ key = suggestion;
+ }
+
+ return (
+
+ {inner}
+
+ );
+ }
+
+ render () {
+ const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
+ const { suggestionsHidden } = this.state;
+ const style = { direction: 'ltr' };
+
+ if (isRtl(value)) {
+ style.direction = 'rtl';
+ }
+
+ return (
+
+
+ {placeholder}
+
+
+
+
+
+ {suggestions.map(this.renderSuggestion)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/avatar.js b/app/javascript/themes/glitch/components/avatar.js
new file mode 100644
index 000000000..dd155f059
--- /dev/null
+++ b/app/javascript/themes/glitch/components/avatar.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class Avatar extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ size: PropTypes.number.isRequired,
+ style: PropTypes.object,
+ animate: PropTypes.bool,
+ inline: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ animate: false,
+ size: 20,
+ inline: false,
+ };
+
+ state = {
+ hovering: false,
+ };
+
+ handleMouseEnter = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: true });
+ }
+
+ handleMouseLeave = () => {
+ if (this.props.animate) return;
+ this.setState({ hovering: false });
+ }
+
+ render () {
+ const { account, size, animate, inline } = this.props;
+ const { hovering } = this.state;
+
+ const src = account.get('avatar');
+ const staticSrc = account.get('avatar_static');
+
+ let className = 'account__avatar';
+
+ if (inline) {
+ className = className + ' account__avatar-inline';
+ }
+
+ const style = {
+ ...this.props.style,
+ width: `${size}px`,
+ height: `${size}px`,
+ backgroundSize: `${size}px ${size}px`,
+ };
+
+ if (hovering || animate) {
+ style.backgroundImage = `url(${src})`;
+ } else {
+ style.backgroundImage = `url(${staticSrc})`;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/avatar_overlay.js b/app/javascript/themes/glitch/components/avatar_overlay.js
new file mode 100644
index 000000000..2ecf9fa44
--- /dev/null
+++ b/app/javascript/themes/glitch/components/avatar_overlay.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class AvatarOverlay extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map.isRequired,
+ };
+
+ render() {
+ const { account, friend } = this.props;
+
+ const baseStyle = {
+ backgroundImage: `url(${account.get('avatar_static')})`,
+ };
+
+ const overlayStyle = {
+ backgroundImage: `url(${friend.get('avatar_static')})`,
+ };
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/button.js b/app/javascript/themes/glitch/components/button.js
new file mode 100644
index 000000000..16868010c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/button.js
@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class Button extends React.PureComponent {
+
+ static propTypes = {
+ text: PropTypes.node,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ block: PropTypes.bool,
+ secondary: PropTypes.bool,
+ size: PropTypes.number,
+ className: PropTypes.string,
+ style: PropTypes.object,
+ children: PropTypes.node,
+ title: PropTypes.string,
+ };
+
+ static defaultProps = {
+ size: 36,
+ };
+
+ handleClick = (e) => {
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ focus() {
+ this.node.focus();
+ }
+
+ render () {
+ let attrs = {
+ className: classNames('button', this.props.className, {
+ 'button-secondary': this.props.secondary,
+ 'button--block': this.props.block,
+ }),
+ disabled: this.props.disabled,
+ onClick: this.handleClick,
+ ref: this.setRef,
+ style: {
+ padding: `0 ${this.props.size / 2.25}px`,
+ height: `${this.props.size}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ },
+ };
+
+ if (this.props.title) attrs.title = this.props.title;
+
+ return (
+
+ {this.props.text || this.props.children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/collapsable.js b/app/javascript/themes/glitch/components/collapsable.js
new file mode 100644
index 000000000..8bc0a54f4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/collapsable.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+
+ {({ opacity, height }) =>
+
+ {children}
+
+ }
+
+);
+
+Collapsable.propTypes = {
+ fullHeight: PropTypes.number.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+};
+
+export default Collapsable;
diff --git a/app/javascript/themes/glitch/components/column.js b/app/javascript/themes/glitch/components/column.js
new file mode 100644
index 000000000..adeba9cc1
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollTop } from 'themes/glitch/util/scroll';
+
+export default class Column extends React.PureComponent {
+
+ static propTypes = {
+ children: PropTypes.node,
+ extraClasses: PropTypes.string,
+ name: PropTypes.string,
+ };
+
+ scrollTop () {
+ const scrollable = this.node.querySelector('.scrollable');
+
+ if (!scrollable) {
+ return;
+ }
+
+ this._interruptScrollAnimation = scrollTop(scrollable);
+ }
+
+ handleWheel = () => {
+ if (typeof this._interruptScrollAnimation !== 'function') {
+ return;
+ }
+
+ this._interruptScrollAnimation();
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ componentDidMount () {
+ this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
+ }
+
+ componentWillUnmount () {
+ this.node.removeEventListener('wheel', this.handleWheel);
+ }
+
+ render () {
+ const { children, extraClasses, name } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_back_button.js b/app/javascript/themes/glitch/components/column_back_button.js
new file mode 100644
index 000000000..50c3bf11f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_back_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButton extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_back_button_slim.js b/app/javascript/themes/glitch/components/column_back_button_slim.js
new file mode 100644
index 000000000..2cdf1b25b
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_back_button_slim.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class ColumnBackButtonSlim extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ handleClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/column_header.js b/app/javascript/themes/glitch/components/column_header.js
new file mode 100644
index 000000000..e601082c8
--- /dev/null
+++ b/app/javascript/themes/glitch/components/column_header.js
@@ -0,0 +1,214 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+// Glitch imports
+import NotificationPurgeButtonsContainer from 'themes/glitch/containers/notification_purge_buttons_container';
+
+const messages = defineMessages({
+ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
+ hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
+ moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
+ moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
+ enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+});
+
+@injectIntl
+export default class ColumnHeader extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ title: PropTypes.node.isRequired,
+ icon: PropTypes.string.isRequired,
+ active: PropTypes.bool,
+ localSettings : ImmutablePropTypes.map,
+ multiColumn: PropTypes.bool,
+ focusable: PropTypes.bool,
+ showBackButton: PropTypes.bool,
+ notifCleaning: PropTypes.bool, // true only for the notification column
+ notifCleaningActive: PropTypes.bool,
+ onEnterCleaningMode: PropTypes.func,
+ children: PropTypes.node,
+ pinned: PropTypes.bool,
+ onPin: PropTypes.func,
+ onMove: PropTypes.func,
+ onClick: PropTypes.func,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ focusable: true,
+ }
+
+ state = {
+ collapsed: true,
+ animating: false,
+ animatingNCD: false,
+ };
+
+ handleToggleClick = (e) => {
+ e.stopPropagation();
+ this.setState({ collapsed: !this.state.collapsed, animating: true });
+ }
+
+ handleTitleClick = () => {
+ this.props.onClick();
+ }
+
+ handleMoveLeft = () => {
+ this.props.onMove(-1);
+ }
+
+ handleMoveRight = () => {
+ this.props.onMove(1);
+ }
+
+ handleBackClick = () => {
+ // if history is exhausted, or we would leave mastodon, just go to root.
+ if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
+ this.context.router.history.push('/');
+ } else {
+ this.context.router.history.goBack();
+ }
+ }
+
+ handleTransitionEnd = () => {
+ this.setState({ animating: false });
+ }
+
+ handleTransitionEndNCD = () => {
+ this.setState({ animatingNCD: false });
+ }
+
+ onEnterCleaningMode = () => {
+ this.setState({ animatingNCD: true });
+ this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
+ }
+
+ render () {
+ const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
+ const { collapsed, animating, animatingNCD } = this.state;
+
+ let title = this.props.title;
+
+ const wrapperClassName = classNames('column-header__wrapper', {
+ 'active': active,
+ });
+
+ const buttonClassName = classNames('column-header', {
+ 'active': active,
+ });
+
+ const collapsibleClassName = classNames('column-header__collapsible', {
+ 'collapsed': collapsed,
+ 'animating': animating,
+ });
+
+ const collapsibleButtonClassName = classNames('column-header__button', {
+ 'active': !collapsed,
+ });
+
+ const notifCleaningButtonClassName = classNames('column-header__button', {
+ 'active': notifCleaningActive,
+ });
+
+ const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
+ 'collapsed': !notifCleaningActive,
+ 'animating': animatingNCD,
+ });
+
+ let extraContent, pinButton, moveButtons, backButton, collapseButton;
+
+ //*glitch
+ const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
+
+ if (children) {
+ extraContent = (
+
+ {children}
+
+ );
+ }
+
+ if (multiColumn && pinned) {
+ pinButton = ;
+
+ moveButtons = (
+
+
+
+
+ );
+ } else if (multiColumn) {
+ pinButton = ;
+ }
+
+ if (!pinned && (multiColumn || showBackButton)) {
+ backButton = (
+
+
+
+
+ );
+ }
+
+ const collapsedContent = [
+ extraContent,
+ ];
+
+ if (multiColumn) {
+ collapsedContent.push(moveButtons);
+ collapsedContent.push(pinButton);
+ }
+
+ if (children || multiColumn) {
+ collapseButton = ;
+ }
+
+ return (
+
+
+
+
+ {title}
+
+
+ {backButton}
+ { notifCleaning ? (
+
+
+
+ ) : null}
+ {collapseButton}
+
+
+
+ { notifCleaning ? (
+
+
+ {(notifCleaningActive || animatingNCD) ? ( ) : null }
+
+
+ ) : null}
+
+
+
+ {(!collapsed || animating) && collapsedContent}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/display_name.js b/app/javascript/themes/glitch/components/display_name.js
new file mode 100644
index 000000000..2cf84f8f4
--- /dev/null
+++ b/app/javascript/themes/glitch/components/display_name.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class DisplayName extends React.PureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ };
+
+ render () {
+ const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+
+ return (
+
+ @{this.props.account.get('acct')}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/dropdown_menu.js b/app/javascript/themes/glitch/components/dropdown_menu.js
new file mode 100644
index 000000000..d30dc2aaf
--- /dev/null
+++ b/app/javascript/themes/glitch/components/dropdown_menu.js
@@ -0,0 +1,211 @@
+import React from 'react';
+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 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class DropdownMenu extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ items: PropTypes.array.isRequired,
+ onClose: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ };
+
+ static defaultProps = {
+ style: {},
+ placement: 'bottom',
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ handleClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.props.onClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ renderItem (option, i) {
+ if (option === null) {
+ return ;
+ }
+
+ const { text, href = '#' } = option;
+
+ return (
+
+
+ {text}
+
+
+ );
+ }
+
+ render () {
+ const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+
+ return (
+
+ {({ opacity, scaleX, scaleY }) => (
+
+
+
+
+ {items.map((option, i) => this.renderItem(option, i))}
+
+
+ )}
+
+ );
+ }
+
+}
+
+export default class Dropdown extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ icon: PropTypes.string.isRequired,
+ items: PropTypes.array.isRequired,
+ size: PropTypes.number.isRequired,
+ ariaLabel: PropTypes.string,
+ disabled: PropTypes.bool,
+ status: ImmutablePropTypes.map,
+ isUserTouching: PropTypes.func,
+ isModalOpen: PropTypes.bool.isRequired,
+ onModalOpen: PropTypes.func,
+ onModalClose: PropTypes.func,
+ };
+
+ static defaultProps = {
+ ariaLabel: 'Menu',
+ };
+
+ state = {
+ expanded: false,
+ };
+
+ handleClick = () => {
+ if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
+ const { status, items } = this.props;
+
+ this.props.onModalOpen({
+ status,
+ actions: items,
+ onClick: this.handleItemClick,
+ });
+
+ return;
+ }
+
+ this.setState({ expanded: !this.state.expanded });
+ }
+
+ handleClose = () => {
+ if (this.props.onModalClose) {
+ this.props.onModalClose();
+ }
+
+ this.setState({ expanded: false });
+ }
+
+ handleKeyDown = e => {
+ switch(e.key) {
+ case 'Enter':
+ this.handleClick();
+ break;
+ case 'Escape':
+ this.handleClose();
+ break;
+ }
+ }
+
+ handleItemClick = e => {
+ const i = Number(e.currentTarget.getAttribute('data-index'));
+ const { action, to } = this.props.items[i];
+
+ this.handleClose();
+
+ if (typeof action === 'function') {
+ e.preventDefault();
+ action();
+ } else if (to) {
+ e.preventDefault();
+ this.context.router.history.push(to);
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { icon, items, size, ariaLabel, disabled } = this.props;
+ const { expanded } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/extended_video_player.js b/app/javascript/themes/glitch/components/extended_video_player.js
new file mode 100644
index 000000000..f8bd067e8
--- /dev/null
+++ b/app/javascript/themes/glitch/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ExtendedVideoPlayer extends React.PureComponent {
+
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ alt: PropTypes.string,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ time: PropTypes.number,
+ controls: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+ };
+
+ handleLoadedData = () => {
+ if (this.props.time) {
+ this.video.currentTime = this.props.time;
+ }
+ }
+
+ componentDidMount () {
+ this.video.addEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ componentWillUnmount () {
+ this.video.removeEventListener('loadeddata', this.handleLoadedData);
+ }
+
+ setRef = (c) => {
+ this.video = c;
+ }
+
+ render () {
+ const { src, muted, controls, alt } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/icon_button.js b/app/javascript/themes/glitch/components/icon_button.js
new file mode 100644
index 000000000..31cdf4703
--- /dev/null
+++ b/app/javascript/themes/glitch/components/icon_button.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import Motion from 'themes/glitch/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 {
+
+ static propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ icon: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ size: PropTypes.number,
+ active: PropTypes.bool,
+ pressed: PropTypes.bool,
+ expanded: PropTypes.bool,
+ style: PropTypes.object,
+ activeStyle: PropTypes.object,
+ disabled: PropTypes.bool,
+ inverted: PropTypes.bool,
+ animate: PropTypes.bool,
+ flip: PropTypes.bool,
+ overlay: PropTypes.bool,
+ tabIndex: PropTypes.string,
+ label: PropTypes.string,
+ };
+
+ static defaultProps = {
+ size: 18,
+ active: false,
+ disabled: false,
+ animate: false,
+ overlay: false,
+ tabIndex: '0',
+ };
+
+ handleClick = (e) => {
+ e.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onClick(e);
+ }
+ }
+
+ render () {
+ let style = {
+ fontSize: `${this.props.size}px`,
+ height: `${this.props.size * 1.28571429}px`,
+ lineHeight: `${this.props.size}px`,
+ ...this.props.style,
+ ...(this.props.active ? this.props.activeStyle : {}),
+ };
+ if (!this.props.label) {
+ style.width = `${this.props.size * 1.28571429}px`;
+ } else {
+ style.textAlign = 'left';
+ }
+
+ const {
+ active,
+ animate,
+ className,
+ disabled,
+ expanded,
+ icon,
+ inverted,
+ flip,
+ overlay,
+ pressed,
+ tabIndex,
+ title,
+ } = this.props;
+
+ const classes = classNames(className, 'icon-button', {
+ active,
+ disabled,
+ inverted,
+ overlayed: overlay,
+ });
+
+ const flipDeg = flip ? -180 : -360;
+ const rotateDeg = active ? flipDeg : 0;
+
+ const motionDefaultStyle = {
+ rotate: rotateDeg,
+ };
+
+ const springOpts = {
+ stiffness: this.props.flip ? 60 : 120,
+ damping: 7,
+ };
+ const motionStyle = {
+ rotate: animate ? spring(rotateDeg, springOpts) : 0,
+ };
+
+ if (!animate) {
+ // Perf optimization: avoid unnecessary components unless
+ // we actually need to animate.
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {({ rotate }) =>
+
+
+ {this.props.label}
+
+ }
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/intersection_observer_article.js b/app/javascript/themes/glitch/components/intersection_observer_article.js
new file mode 100644
index 000000000..f0139ac75
--- /dev/null
+++ b/app/javascript/themes/glitch/components/intersection_observer_article.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import scheduleIdleTask from 'themes/glitch/util/schedule_idle_task';
+import getRectFromEntry from 'themes/glitch/util/get_rect_from_entry';
+import { is } from 'immutable';
+
+// Diff these props in the "rendered" state
+const updateOnPropsForRendered = ['id', 'index', 'listLength'];
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
+
+ static propTypes = {
+ intersectionObserverWrapper: PropTypes.object.isRequired,
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ saveHeightKey: PropTypes.string,
+ cachedHeight: PropTypes.number,
+ onHeightChange: PropTypes.func,
+ children: PropTypes.node,
+ };
+
+ state = {
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+ const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+ if (!!isUnrendered !== !!willBeUnrendered) {
+ // If we're going from rendered to unrendered (or vice versa) then update
+ return true;
+ }
+ // Otherwise, diff based on props
+ const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
+ return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+ }
+
+ componentDidMount () {
+ const { intersectionObserverWrapper, id } = this.props;
+
+ intersectionObserverWrapper.observe(
+ id,
+ this.node,
+ this.handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ const { intersectionObserverWrapper, id } = this.props;
+ intersectionObserverWrapper.unobserve(id, this.node);
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ this.entry = entry;
+
+ scheduleIdleTask(this.calculateHeight);
+ this.setState(this.updateStateAfterIntersection);
+ }
+
+ updateStateAfterIntersection = (prevState) => {
+ if (prevState.isIntersecting && !this.entry.isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: this.entry.isIntersecting,
+ isHidden: false,
+ };
+ }
+
+ calculateHeight = () => {
+ const { onHeightChange, saveHeightKey, id } = this.props;
+ // save the height of the fully-rendered element (this is expensive
+ // on Chrome, where we need to fall back to getBoundingClientRect)
+ this.height = getRectFromEntry(this.entry).height;
+
+ if (onHeightChange && saveHeightKey) {
+ onHeightChange(saveHeightKey, id, this.height);
+ }
+ }
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/tootsuite/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ }
+
+ handleRef = (node) => {
+ this.node = node;
+ }
+
+ render () {
+ const { children, id, index, listLength, cachedHeight } = this.props;
+ const { isIntersecting, isHidden } = this.state;
+
+ if (!isIntersecting && (isHidden || cachedHeight)) {
+ return (
+
+ {children && React.cloneElement(children, { hidden: true })}
+
+ );
+ }
+
+ return (
+
+ {children && React.cloneElement(children, { hidden: false })}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/load_more.js b/app/javascript/themes/glitch/components/load_more.js
new file mode 100644
index 000000000..c4c8c94a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/load_more.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+export default class LoadMore extends React.PureComponent {
+
+ static propTypes = {
+ onClick: PropTypes.func,
+ visible: PropTypes.bool,
+ }
+
+ static defaultProps = {
+ visible: true,
+ }
+
+ render() {
+ const { visible } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/loading_indicator.js b/app/javascript/themes/glitch/components/loading_indicator.js
new file mode 100644
index 000000000..d6a5adb6f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/loading_indicator.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/themes/glitch/components/media_gallery.js b/app/javascript/themes/glitch/components/media_gallery.js
new file mode 100644
index 000000000..05390c82f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/media_gallery.js
@@ -0,0 +1,256 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { is } from 'immutable';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from 'themes/glitch/util/is_mobile';
+import classNames from 'classnames';
+import { autoPlayGif } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+});
+
+class Item extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ standalone: PropTypes.bool,
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ letterbox: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ index: 0,
+ size: 1,
+ };
+
+ handleMouseEnter = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ }
+
+ handleMouseLeave = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ }
+
+ hoverToPlay () {
+ const { attachment } = this.props;
+ return !autoPlayGif && attachment.get('type') === 'gifv';
+ }
+
+ handleClick = (e) => {
+ const { index, onClick } = this.props;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ }
+
+ render () {
+ const { attachment, index, size, standalone, letterbox } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'image') {
+ const previewUrl = attachment.get('preview_url');
+ const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+ const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+ const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
+ const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ const autoPlay = !isIOS() && autoPlayGif;
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ }
+
+ return (
+
+ {thumbnail}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class MediaGallery extends React.PureComponent {
+
+ static propTypes = {
+ sensitive: PropTypes.bool,
+ standalone: PropTypes.bool,
+ letterbox: PropTypes.bool,
+ fullwidth: PropTypes.bool,
+ media: ImmutablePropTypes.list.isRequired,
+ size: PropTypes.object,
+ onOpenMedia: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ standalone: false,
+ };
+
+ state = {
+ visible: !this.props.sensitive,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (!is(nextProps.media, this.props.media)) {
+ this.setState({ visible: !nextProps.sensitive });
+ }
+ }
+
+ handleOpen = () => {
+ this.setState({ visible: !this.state.visible });
+ }
+
+ handleClick = (index) => {
+ this.props.onOpenMedia(this.props.media, index);
+ }
+
+ isStandaloneEligible() {
+ const { media, standalone } = this.props;
+ return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+ }
+
+ render () {
+ const { media, intl, sensitive, letterbox, fullwidth } = this.props;
+ const { visible } = this.state;
+
+ let children;
+
+ if (!visible) {
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ children = (
+
+ {warning}
+
+
+ );
+ } else {
+ const size = media.take(4).size;
+
+ if (this.isStandaloneEligible()) {
+ children = ;
+ } else {
+ children = media.take(4).map((attachment, i) => );
+ }
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/missing_indicator.js b/app/javascript/themes/glitch/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/themes/glitch/components/notification_purge_buttons.js b/app/javascript/themes/glitch/components/notification_purge_buttons.js
new file mode 100644
index 000000000..e0c1543b0
--- /dev/null
+++ b/app/javascript/themes/glitch/components/notification_purge_buttons.js
@@ -0,0 +1,58 @@
+/**
+ * Buttons widget for controlling the notification clearing mode.
+ * In idle state, the cleaning mode button is shown. When the mode is active,
+ * a Confirm and Abort buttons are shown in its place.
+ */
+
+
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
+ btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
+ btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
+ btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
+});
+
+@injectIntl
+export default class NotificationPurgeButtons extends ImmutablePureComponent {
+
+ static propTypes = {
+ onDeleteMarked : PropTypes.func.isRequired,
+ onMarkAll : PropTypes.func.isRequired,
+ onMarkNone : PropTypes.func.isRequired,
+ onInvert : PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ markNewForDelete: PropTypes.bool,
+ };
+
+ render () {
+ const { intl, markNewForDelete } = this.props;
+
+ //className='active'
+ return (
+
+
+ ∀ {intl.formatMessage(messages.btnAll)}
+
+
+
+ ∅ {intl.formatMessage(messages.btnNone)}
+
+
+
+ ¬ {intl.formatMessage(messages.btnInvert)}
+
+
+
+ {intl.formatMessage(messages.btnApply)}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/permalink.js b/app/javascript/themes/glitch/components/permalink.js
new file mode 100644
index 000000000..d726d37a2
--- /dev/null
+++ b/app/javascript/themes/glitch/components/permalink.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Permalink extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ className: PropTypes.string,
+ href: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ };
+
+ handleClick = (e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(this.props.to);
+ }
+ }
+
+ render () {
+ const { href, children, className, ...other } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/relative_timestamp.js b/app/javascript/themes/glitch/components/relative_timestamp.js
new file mode 100644
index 000000000..51588e78c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/relative_timestamp.js
@@ -0,0 +1,147 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const messages = defineMessages({
+ just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+ seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+ minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+ hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+ days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
+const dateFormatOptions = {
+ hour12: false,
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+};
+
+const shortDateFormatOptions = {
+ month: 'numeric',
+ day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR = 1000 * 60 * 60;
+const DAY = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+ const absDelta = Math.abs(delta);
+
+ if (absDelta < MINUTE) {
+ return 'second';
+ } else if (absDelta < HOUR) {
+ return 'minute';
+ } else if (absDelta < DAY) {
+ return 'hour';
+ }
+
+ return 'day';
+};
+
+const getUnitDelay = units => {
+ switch (units) {
+ case 'second':
+ return SECOND;
+ case 'minute':
+ return MINUTE;
+ case 'hour':
+ return HOUR;
+ case 'day':
+ return DAY;
+ default:
+ return MAX_DELAY;
+ }
+};
+
+@injectIntl
+export default class RelativeTimestamp extends React.Component {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ timestamp: PropTypes.string.isRequired,
+ };
+
+ state = {
+ now: this.props.intl.now(),
+ };
+
+ shouldComponentUpdate (nextProps, nextState) {
+ // As of right now the locale doesn't change without a new page load,
+ // but we might as well check in case that ever changes.
+ return this.props.timestamp !== nextProps.timestamp ||
+ this.props.intl.locale !== nextProps.intl.locale ||
+ this.state.now !== nextState.now;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.timestamp !== nextProps.timestamp) {
+ this.setState({ now: this.props.intl.now() });
+ }
+ }
+
+ componentDidMount () {
+ this._scheduleNextUpdate(this.props, this.state);
+ }
+
+ componentWillUpdate (nextProps, nextState) {
+ this._scheduleNextUpdate(nextProps, nextState);
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this._timer);
+ }
+
+ _scheduleNextUpdate (props, state) {
+ clearTimeout(this._timer);
+
+ const { timestamp } = props;
+ const delta = (new Date(timestamp)).getTime() - state.now;
+ const unitDelay = getUnitDelay(selectUnits(delta));
+ const unitRemainder = Math.abs(delta % unitDelay);
+ const updateInterval = 1000 * 10;
+ const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+ this._timer = setTimeout(() => {
+ this.setState({ now: this.props.intl.now() });
+ }, delay);
+ }
+
+ render () {
+ const { timestamp, intl } = this.props;
+
+ const date = new Date(timestamp);
+ const delta = this.state.now - date.getTime();
+
+ let relativeTime;
+
+ if (delta < 10 * SECOND) {
+ relativeTime = intl.formatMessage(messages.just_now);
+ } else if (delta < 3 * DAY) {
+ if (delta < MINUTE) {
+ relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+ } else if (delta < HOUR) {
+ relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+ } else if (delta < DAY) {
+ relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+ } else {
+ relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+ }
+ } else {
+ relativeTime = intl.formatDate(date, shortDateFormatOptions);
+ }
+
+ return (
+
+ {relativeTime}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/scrollable_list.js b/app/javascript/themes/glitch/components/scrollable_list.js
new file mode 100644
index 000000000..ccdcd7c85
--- /dev/null
+++ b/app/javascript/themes/glitch/components/scrollable_list.js
@@ -0,0 +1,198 @@
+import React, { PureComponent } from 'react';
+import { ScrollContainer } from 'react-router-scroll-4';
+import PropTypes from 'prop-types';
+import IntersectionObserverArticleContainer from 'themes/glitch/containers/intersection_observer_article_container';
+import LoadMore from './load_more';
+import IntersectionObserverWrapper from 'themes/glitch/util/intersection_observer_wrapper';
+import { throttle } from 'lodash';
+import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen';
+
+export default class ScrollableList extends PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ children: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ state = {
+ lastMouseMove: null,
+ };
+
+ intersectionObserverWrapper = new IntersectionObserverWrapper();
+
+ handleScroll = throttle(() => {
+ if (this.node) {
+ const { scrollTop, scrollHeight, clientHeight } = this.node;
+ const offset = scrollHeight - scrollTop - clientHeight;
+ this._oldScrollPosition = scrollHeight - scrollTop;
+
+ if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+ this.props.onScrollToBottom();
+ } else if (scrollTop < 100 && this.props.onScrollToTop) {
+ this.props.onScrollToTop();
+ } else if (this.props.onScroll) {
+ this.props.onScroll();
+ }
+ }
+ }, 150, {
+ trailing: true,
+ });
+
+ handleMouseMove = throttle(() => {
+ this._lastMouseMove = new Date();
+ }, 300);
+
+ handleMouseLeave = () => {
+ this._lastMouseMove = null;
+ }
+
+ componentDidMount () {
+ this.attachScrollListener();
+ this.attachIntersectionObserver();
+ attachFullscreenListener(this.onFullScreenChange);
+
+ // Handle initial scroll posiiton
+ this.handleScroll();
+ }
+
+ componentDidUpdate (prevProps) {
+ const someItemInserted = React.Children.count(prevProps.children) > 0 &&
+ React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
+ this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
+
+ // Reset the scroll position when a new child comes in in order not to
+ // jerk the scrollbar around if you're already scrolled down the page.
+ if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
+ const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
+
+ if (this.node.scrollTop !== newScrollTop) {
+ this.node.scrollTop = newScrollTop;
+ }
+ } else {
+ this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
+ }
+ }
+
+ componentWillUnmount () {
+ this.detachScrollListener();
+ this.detachIntersectionObserver();
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
+ attachIntersectionObserver () {
+ this.intersectionObserverWrapper.connect({
+ root: this.node,
+ rootMargin: '300% 0px',
+ });
+ }
+
+ detachIntersectionObserver () {
+ this.intersectionObserverWrapper.disconnect();
+ }
+
+ attachScrollListener () {
+ this.node.addEventListener('scroll', this.handleScroll);
+ }
+
+ detachScrollListener () {
+ this.node.removeEventListener('scroll', this.handleScroll);
+ }
+
+ getFirstChildKey (props) {
+ const { children } = props;
+ let firstChild = children;
+ if (children instanceof ImmutableList) {
+ firstChild = children.get(0);
+ } else if (Array.isArray(children)) {
+ firstChild = children[0];
+ }
+ return firstChild && firstChild.key;
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ handleLoadMore = (e) => {
+ e.preventDefault();
+ this.props.onScrollToBottom();
+ }
+
+ _recentlyMoved () {
+ return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
+ }
+
+ render () {
+ const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+ const { fullscreen } = this.state;
+ const childrenCount = React.Children.count(children);
+
+ const loadMore = (hasMore && childrenCount > 0) ? : null;
+ let scrollableArea = null;
+
+ if (isLoading || childrenCount > 0 || !emptyMessage) {
+ scrollableArea = (
+
+
+ {prepend}
+
+ {React.Children.map(this.props.children, (child, index) => (
+
+ {child}
+
+ ))}
+
+ {loadMore}
+
+
+ );
+ } else {
+ scrollableArea = (
+
+ {emptyMessage}
+
+ );
+ }
+
+ if (trackScroll) {
+ return (
+
+ {scrollableArea}
+
+ );
+ } else {
+ return scrollableArea;
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/setting_text.js b/app/javascript/themes/glitch/components/setting_text.js
new file mode 100644
index 000000000..a6dde4c0f
--- /dev/null
+++ b/app/javascript/themes/glitch/components/setting_text.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+export default class SettingText extends React.PureComponent {
+
+ static propTypes = {
+ settings: ImmutablePropTypes.map.isRequired,
+ settingKey: PropTypes.array.isRequired,
+ label: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ };
+
+ handleChange = (e) => {
+ this.props.onChange(this.props.settingKey, e.target.value);
+ }
+
+ render () {
+ const { settings, settingKey, label } = this.props;
+
+ return (
+
+ {label}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status.js b/app/javascript/themes/glitch/components/status.js
new file mode 100644
index 000000000..cf2fbe21e
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status.js
@@ -0,0 +1,436 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusPrepend from './status_prepend';
+import StatusHeader from './status_header';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { MediaGallery, Video } from 'themes/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import NotificationOverlayContainer from 'themes/glitch/features/notifications/containers/overlay_container';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ id: PropTypes.string,
+ status: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.map,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onPin: PropTypes.func,
+ onOpenMedia: PropTypes.func,
+ onOpenVideo: PropTypes.func,
+ onBlock: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onHeightChange: PropTypes.func,
+ muted: PropTypes.bool,
+ collapse: PropTypes.bool,
+ hidden: PropTypes.bool,
+ prepend: PropTypes.string,
+ withDismiss: PropTypes.bool,
+ onMoveUp: PropTypes.func,
+ onMoveDown: PropTypes.func,
+ };
+
+ state = {
+ isExpanded: null,
+ markedForDelete: false,
+ }
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'settings',
+ 'prepend',
+ 'boostModal',
+ 'muted',
+ 'collapse',
+ 'notification',
+ ]
+
+ updateOnStates = [
+ 'isExpanded',
+ 'markedForDelete',
+ ]
+
+ // If our settings have changed to disable collapsed statuses, then we
+ // need to make sure that we uncollapse every one. We do that by watching
+ // for changes to `settings.collapsed.enabled` in
+ // `componentWillReceiveProps()`.
+
+ // We also need to watch for changes on the `collapse` prop---if this
+ // changes to anything other than `undefined`, then we need to collapse or
+ // uncollapse our status accordingly.
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+ if (this.state.isExpanded === false) {
+ this.setExpansion(null);
+ }
+ } else if (
+ nextProps.collapse !== this.props.collapse &&
+ nextProps.collapse !== undefined
+ ) this.setExpansion(nextProps.collapse ? false : null);
+ }
+
+ // When mounting, we just check to see if our status should be collapsed,
+ // and collapse it if so. We don't need to worry about whether collapsing
+ // is enabled here, because `setExpansion()` already takes that into
+ // account.
+
+ // The cases where a status should be collapsed are:
+ //
+ // - The `collapse` prop has been set to `true`
+ // - The user has decided in local settings to collapse all statuses.
+ // - The user has decided to collapse all notifications ('muted'
+ // statuses).
+ // - The user has decided to collapse long statuses and the status is
+ // over 400px (without media, or 650px with).
+ // - The status is a reply and the user has decided to collapse all
+ // replies.
+ // - The status contains media and the user has decided to collapse all
+ // statuses with media.
+ // - The status is a reblog the user has decided to collapse all
+ // statuses which are reblogs.
+ componentDidMount () {
+ const { node } = this;
+ const {
+ status,
+ settings,
+ collapse,
+ muted,
+ prepend,
+ } = this.props;
+ const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+ if (function () {
+ switch (true) {
+ case collapse:
+ case autoCollapseSettings.get('all'):
+ case autoCollapseSettings.get('notifications') && muted:
+ case autoCollapseSettings.get('lengthy') && node.clientHeight > (
+ status.get('media_attachments').size && !muted ? 650 : 400
+ ):
+ case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
+ case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
+ case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size:
+ return true;
+ default:
+ return false;
+ }
+ }()) this.setExpansion(false);
+ }
+
+ // `setExpansion()` sets the value of `isExpanded` in our state. It takes
+ // one argument, `value`, which gives the desired value for `isExpanded`.
+ // The default for this argument is `null`.
+
+ // `setExpansion()` automatically checks for us whether toot collapsing
+ // is enabled, so we don't have to.
+ setExpansion = (value) => {
+ switch (true) {
+ case value === undefined || value === null:
+ this.setState({ isExpanded: null });
+ break;
+ case !value && this.props.settings.getIn(['collapsed', 'enabled']):
+ this.setState({ isExpanded: false });
+ break;
+ case !!value:
+ this.setState({ isExpanded: true });
+ break;
+ }
+ }
+
+ // `parseClick()` takes a click event and responds appropriately.
+ // If our status is collapsed, then clicking on it should uncollapse it.
+ // If `Shift` is held, then clicking on it should collapse it.
+ // Otherwise, we open the url handed to us in `destination`, if
+ // applicable.
+ parseClick = (e, destination) => {
+ const { router } = this.context;
+ const { status } = this.props;
+ const { isExpanded } = this.state;
+ if (!router) return;
+ if (destination === undefined) {
+ destination = `/statuses/${
+ status.getIn(['reblog', 'id'], status.get('id'))
+ }`;
+ }
+ if (e.button === 0) {
+ if (isExpanded === false) this.setExpansion(null);
+ else if (e.shiftKey) {
+ this.setExpansion(false);
+ document.getSelection().removeAllRanges();
+ } else router.history.push(destination);
+ e.preventDefault();
+ }
+ }
+
+ handleAccountClick = (e) => {
+ if (this.context.router && e.button === 0) {
+ const id = e.currentTarget.getAttribute('data-id');
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${id}`);
+ }
+ }
+
+ handleExpandedToggle = () => {
+ this.setExpansion(this.state.isExpanded || !this.props.status.get('spoiler') ? null : true);
+ };
+
+ handleOpenVideo = startTime => {
+ this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+ }
+
+ handleHotkeyReply = e => {
+ e.preventDefault();
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleHotkeyFavourite = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleHotkeyBoost = e => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleHotkeyMention = e => {
+ e.preventDefault();
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleHotkeyOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleHotkeyOpenProfile = () => {
+ this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+ }
+
+ handleHotkeyMoveUp = () => {
+ this.props.onMoveUp(this.props.status.get('id'));
+ }
+
+ handleHotkeyMoveDown = () => {
+ this.props.onMoveDown(this.props.status.get('id'));
+ }
+
+ renderLoadingMediaGallery () {
+ return
;
+ }
+
+ renderLoadingVideoPlayer () {
+ return
;
+ }
+
+ render () {
+ const {
+ parseClick,
+ setExpansion,
+ } = this;
+ const { router } = this.context;
+ const {
+ status,
+ account,
+ settings,
+ collapsed,
+ muted,
+ prepend,
+ intersectionObserverWrapper,
+ onOpenVideo,
+ onOpenMedia,
+ notification,
+ hidden,
+ ...other
+ } = this.props;
+ const { isExpanded } = this.state;
+ let background = null;
+ let attachments = null;
+ let media = null;
+ let mediaIcon = null;
+
+ if (status === null) {
+ return null;
+ }
+
+ if (hidden) {
+ return (
+
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {' '}
+ {status.get('content')}
+
+ );
+ }
+
+ // If user backgrounds for collapsed statuses are enabled, then we
+ // initialize our background accordingly. This will only be rendered if
+ // the status is collapsed.
+ if (settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])) {
+ background = status.getIn(['account', 'header']);
+ }
+
+ // This handles our media attachments. Note that we don't show media on
+ // muted (notification) statuses. If the media type is unknown, then we
+ // simply ignore it.
+
+ // After we have generated our appropriate media element and stored it in
+ // `media`, we snatch the thumbnail to use as our `background` if media
+ // backgrounds for collapsed statuses are enabled.
+ attachments = status.get('media_attachments');
+ if (attachments.size > 0 && !muted) {
+ if (attachments.some(item => item.get('type') === 'unknown')) { // Media type is 'unknown'
+ /* Do nothing */
+ } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video'
+ const video = status.getIn(['media_attachments', 0]);
+
+ media = (
+
+ {Component => }
+
+ );
+ mediaIcon = 'video-camera';
+ } else { // Media type is 'image' or 'gifv'
+ media = (
+
+ {Component => (
+
+ )}
+
+ );
+ mediaIcon = 'picture-o';
+ }
+
+ if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
+ background = attachments.getIn([0, 'preview_url']);
+ }
+ }
+
+ // Here we prepare extra data-* attributes for CSS selectors.
+ // Users can use those for theming, hiding avatars etc via UserStyle
+ const selectorAttribs = {
+ 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+ };
+
+ if (prepend && account) {
+ const notifKind = {
+ favourite: 'favourited',
+ reblog: 'boosted',
+ reblogged_by: 'boosted',
+ }[prepend];
+
+ selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+ }
+
+ const handlers = {
+ reply: this.handleHotkeyReply,
+ favourite: this.handleHotkeyFavourite,
+ boost: this.handleHotkeyBoost,
+ mention: this.handleHotkeyMention,
+ open: this.handleHotkeyOpen,
+ openProfile: this.handleHotkeyOpenProfile,
+ moveUp: this.handleHotkeyMoveUp,
+ moveDown: this.handleHotkeyMoveDown,
+ };
+
+ return (
+
+
+ {prepend && account ? (
+
+ ) : null}
+
+
+ {isExpanded !== false ? (
+
+ ) : null}
+ {notification ? (
+
+ ) : null}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_action_bar.js b/app/javascript/themes/glitch/components/status_action_bar.js
new file mode 100644
index 000000000..9d615ed7c
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_action_bar.js
@@ -0,0 +1,188 @@
+// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
+// SEE INSTEAD : glitch/components/status/action_bar
+
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ 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' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onReport: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ onPin: PropTypes.func,
+ withDismiss: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'withDismiss',
+ ]
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleShareClick = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleMuteClick = () => {
+ this.props.onMute(this.props.status.get('account'));
+ }
+
+ handleBlockClick = () => {
+ this.props.onBlock(this.props.status.get('account'));
+ }
+
+ handleOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ }
+
+ render () {
+ const { status, intl, withDismiss } = this.props;
+
+ const mutingConversation = status.get('muted');
+ const anonymousAccess = !me;
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+ let reblogIcon = 'retweet';
+ let replyIcon;
+ let replyTitle;
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ menu.push(null);
+
+ if (status.getIn(['account', 'id']) === me || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (status.getIn(['account', 'id']) === me) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+
+ );
+
+ return (
+
+
+
+
+ {shareButton}
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_content.js b/app/javascript/themes/glitch/components/status_content.js
new file mode 100644
index 000000000..3eba6eaa0
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_content.js
@@ -0,0 +1,245 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'themes/glitch/util/rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+import classnames from 'classnames';
+
+export default class StatusContent extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ expanded: PropTypes.bool,
+ setExpansion: PropTypes.func,
+ media: PropTypes.element,
+ mediaIcon: PropTypes.string,
+ parseClick: PropTypes.func,
+ disabled: PropTypes.bool,
+ };
+
+ state = {
+ hidden: true,
+ };
+
+ _updateStatusLinks () {
+ const node = this.node;
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+ link.classList.add('status-link');
+
+ let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.addEventListener('click', this.onLinkClick.bind(this), false);
+ link.setAttribute('title', link.href);
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener');
+ }
+ }
+
+ componentDidMount () {
+ this._updateStatusLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateStatusLinks();
+ }
+
+ onLinkClick = (e) => {
+ if (this.props.expanded === false) {
+ if (this.props.parseClick) this.props.parseClick(e);
+ }
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.props.parseClick) {
+ this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (this.props.parseClick) {
+ this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleMouseDown = (e) => {
+ this.startXY = [e.clientX, e.clientY];
+ }
+
+ handleMouseUp = (e) => {
+ const { parseClick } = this.props;
+
+ if (!this.startXY) {
+ return;
+ }
+
+ const [ startX, startY ] = this.startXY;
+ const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+ if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+ return;
+ }
+
+ if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
+ parseClick(e);
+ }
+
+ this.startXY = null;
+ }
+
+ handleSpoilerClick = (e) => {
+ e.preventDefault();
+
+ if (this.props.setExpansion) {
+ this.props.setExpansion(this.props.expanded ? null : true);
+ } else {
+ this.setState({ hidden: !this.state.hidden });
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const {
+ status,
+ media,
+ mediaIcon,
+ parseClick,
+ disabled,
+ } = this.props;
+
+ const hidden = this.props.setExpansion ? !this.props.expanded : this.state.hidden;
+
+ const content = { __html: status.get('contentHtml') };
+ const spoilerContent = { __html: status.get('spoilerHtml') };
+ const directionStyle = { direction: 'ltr' };
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': parseClick && !disabled,
+ 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
+ });
+
+ if (isRtl(status.get('search_index'))) {
+ directionStyle.direction = 'rtl';
+ }
+
+ if (status.get('spoiler_text').length > 0) {
+ let mentionsPlaceholder = '';
+
+ const mentionLinks = status.get('mentions').map(item => (
+
+ @{item.get('username')}
+
+ )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+ const toggleText = hidden ? [
+ ,
+ mediaIcon ? (
+
+ ) : null,
+ ] : [
+ ,
+ ];
+
+ if (hidden) {
+ mentionsPlaceholder = {mentionLinks}
;
+ }
+
+ return (
+
+
+
+ {' '}
+
+ {toggleText}
+
+
+
+ {mentionsPlaceholder}
+
+
+
+
+ );
+ } else if (parseClick) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_header.js b/app/javascript/themes/glitch/components/status_header.js
new file mode 100644
index 000000000..bfa996cd5
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_header.js
@@ -0,0 +1,120 @@
+// Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Mastodon imports.
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import DisplayName from './display_name';
+import IconButton from './icon_button';
+import VisibilityIcon from './status_visibility_icon';
+
+// Messages for use with internationalization stuff.
+const messages = defineMessages({
+ collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
+ uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
+ public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class StatusHeader extends React.PureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ friend: ImmutablePropTypes.map,
+ mediaIcon: PropTypes.string,
+ collapsible: PropTypes.bool,
+ collapsed: PropTypes.bool,
+ parseClick: PropTypes.func.isRequired,
+ setExpansion: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Handles clicks on collapsed button
+ handleCollapsedClick = (e) => {
+ const { collapsed, setExpansion } = this.props;
+ if (e.button === 0) {
+ setExpansion(collapsed ? null : false);
+ e.preventDefault();
+ }
+ }
+
+ // Handles clicks on account name/image
+ handleAccountClick = (e) => {
+ const { status, parseClick } = this.props;
+ parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`);
+ }
+
+ // Rendering.
+ render () {
+ const {
+ status,
+ friend,
+ mediaIcon,
+ collapsible,
+ collapsed,
+ intl,
+ } = this.props;
+
+ const account = status.get('account');
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_list.js b/app/javascript/themes/glitch/components/status_list.js
new file mode 100644
index 000000000..ddb1354c6
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_list.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from './scrollable_list';
+
+export default class StatusList extends ImmutablePureComponent {
+
+ static propTypes = {
+ scrollKey: PropTypes.string.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ onScrollToBottom: PropTypes.func,
+ onScrollToTop: PropTypes.func,
+ onScroll: PropTypes.func,
+ trackScroll: PropTypes.bool,
+ shouldUpdateScroll: PropTypes.func,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ prepend: PropTypes.node,
+ emptyMessage: PropTypes.node,
+ };
+
+ static defaultProps = {
+ trackScroll: true,
+ };
+
+ handleMoveUp = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) - 1;
+ this._selectChild(elementIndex);
+ }
+
+ handleMoveDown = id => {
+ const elementIndex = this.props.statusIds.indexOf(id) + 1;
+ this._selectChild(elementIndex);
+ }
+
+ _selectChild (index) {
+ const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+ if (element) {
+ element.focus();
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { statusIds, ...other } = this.props;
+ const { isLoading } = other;
+
+ const scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map((statusId) => (
+
+ ))
+ ) : null;
+
+ return (
+
+ {scrollableContent}
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_prepend.js b/app/javascript/themes/glitch/components/status_prepend.js
new file mode 100644
index 000000000..bd2559e46
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_prepend.js
@@ -0,0 +1,83 @@
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+
+export default class StatusPrepend extends React.PureComponent {
+
+ static propTypes = {
+ type: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ parseClick: PropTypes.func.isRequired,
+ notificationId: PropTypes.number,
+ };
+
+ handleClick = (e) => {
+ const { account, parseClick } = this.props;
+ parseClick(e, `/accounts/${+account.get('id')}`);
+ }
+
+ Message = () => {
+ const { type, account } = this.props;
+ let link = (
+
+
+
+ );
+ switch (type) {
+ case 'reblogged_by':
+ return (
+
+ );
+ case 'favourite':
+ return (
+
+ );
+ case 'reblog':
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ render () {
+ const { Message } = this;
+ const { type } = this.props;
+
+ return !type ? null : (
+
+ );
+ }
+
+}
diff --git a/app/javascript/themes/glitch/components/status_visibility_icon.js b/app/javascript/themes/glitch/components/status_visibility_icon.js
new file mode 100644
index 000000000..017b69cbb
--- /dev/null
+++ b/app/javascript/themes/glitch/components/status_visibility_icon.js
@@ -0,0 +1,48 @@
+// Package imports //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ public: { id: 'privacy.public.short', defaultMessage: 'Public' },
+ unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+ private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+ direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+});
+
+@injectIntl
+export default class VisibilityIcon extends ImmutablePureComponent {
+
+ static propTypes = {
+ visibility: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ withLabel: PropTypes.bool,
+ };
+
+ render() {
+ const { withLabel, visibility, intl } = this.props;
+
+ const visibilityClass = {
+ public: 'globe',
+ unlisted: 'unlock-alt',
+ private: 'lock',
+ direct: 'envelope',
+ }[visibility];
+
+ const label = intl.formatMessage(messages[visibility]);
+
+ const icon = ( );
+
+ if (withLabel) {
+ return ({icon} {label} );
+ } else {
+ return icon;
+ }
+ }
+
+}
--
cgit