about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/containers
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/containers')
-rw-r--r--app/javascript/flavours/glitch/containers/account_container.js72
-rw-r--r--app/javascript/flavours/glitch/containers/compose_container.js41
-rw-r--r--app/javascript/flavours/glitch/containers/domain_container.js33
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js33
-rw-r--r--app/javascript/flavours/glitch/containers/intersection_observer_article_container.js17
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js67
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js103
-rw-r--r--app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js49
-rw-r--r--app/javascript/flavours/glitch/containers/poll_container.js25
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js260
-rw-r--r--app/javascript/flavours/glitch/containers/timeline_container.js62
11 files changed, 762 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/containers/account_container.js b/app/javascript/flavours/glitch/containers/account_container.js
new file mode 100644
index 000000000..bc84d299b
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/account_container.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Account from 'flavours/glitch/components/account';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
+} from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { unfollowModal } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unfollowConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        dispatch(unfollowAccount(account.get('id')));
+      }
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+
+  onMuteNotifications (account, notifications) {
+    dispatch(muteAccount(account.get('id'), notifications));
+  },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/containers/compose_container.js b/app/javascript/flavours/glitch/containers/compose_container.js
new file mode 100644
index 000000000..74c411b7c
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/compose_container.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import Compose from 'flavours/glitch/features/standalone/compose';
+import initialState from 'flavours/glitch/util/initial_state';
+import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+  store.dispatch(hydrateStore(initialState));
+}
+
+store.dispatch(fetchCustomEmojis());
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <Compose />
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/domain_container.js b/app/javascript/flavours/glitch/containers/domain_container.js
new file mode 100644
index 000000000..e92e102ab
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/domain_container.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { blockDomain, unblockDomain } from '../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Domain from '../components/domain';
+import { openModal } from '../actions/modal';
+
+const messages = defineMessages({
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = (state, { }) => ({
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onBlockDomain (domain) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockDomainConfirm),
+      onConfirm: () => dispatch(blockDomain(domain)),
+    }));
+  },
+
+  onUnblockDomain (domain) {
+    dispatch(unblockDomain(domain));
+  },
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain));
diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
new file mode 100644
index 000000000..e60ee814d
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -0,0 +1,33 @@
+import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import { connect } from 'react-redux';
+import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+
+const mapStateToProps = state => ({
+  isModalOpen: state.get('modal').modalType === 'ACTIONS',
+  dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
+  openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
+});
+
+const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
+  onOpen(id, onItemClick, dropdownPlacement, keyboard) {
+    dispatch(isUserTouching() ? openModal('ACTIONS', {
+      status,
+      actions: items.map(
+        (item, i) => item ? {
+          ...item,
+          name: `${item.text}-${i}`,
+          onClick: item.action ? ((e) => { return onItemClick(i, e) }) : null,
+        } : null
+      ),
+    }) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey));
+  },
+  onClose(id) {
+    dispatch(closeModal('ACTIONS'));
+    dispatch(closeDropdownMenu(id));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js
new file mode 100644
index 000000000..f2741f2d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/intersection_observer_article_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import IntersectionObserverArticle from 'flavours/glitch/components/intersection_observer_article';
+import { setHeight } from 'flavours/glitch/actions/height_cache';
+
+const makeMapStateToProps = (state, props) => ({
+  cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onHeightChange (key, id, height) {
+    dispatch(setHeight(key, id, height));
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
new file mode 100644
index 000000000..762280bec
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { showOnboardingOnce } from 'flavours/glitch/actions/onboarding';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll-4';
+import UI from 'flavours/glitch/features/ui';
+import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { connectUserStream } from 'flavours/glitch/actions/streaming';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'locales';
+import initialState from 'flavours/glitch/util/initial_state';
+import ErrorBoundary from 'flavours/glitch/components/error_boundary';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export const store = configureStore();
+const hydrateAction = hydrateStore(initialState);
+store.dispatch(hydrateAction);
+
+// load custom emojis
+store.dispatch(fetchCustomEmojis());
+
+export default class Mastodon extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+  };
+
+  componentDidMount() {
+    this.disconnect = store.dispatch(connectUserStream());
+    store.dispatch(showOnboardingOnce());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  shouldUpdateScroll (_, { location }) {
+    return !(location.state && location.state.mastodonModalOpen);
+  }
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <ErrorBoundary>
+            <BrowserRouter basename='/web'>
+              <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
+                <Route path='/' component={UI} />
+              </ScrollContext>
+            </BrowserRouter>
+          </ErrorBoundary>
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
new file mode 100644
index 000000000..a483510b0
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -0,0 +1,103 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { List as ImmutableList, fromJS } from 'immutable';
+import { getLocale } from 'mastodon/locales';
+import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import Poll from 'flavours/glitch/components/poll';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import ModalRoot from 'flavours/glitch/components/modal_root';
+import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import Video from 'flavours/glitch/features/video';
+import Card from 'flavours/glitch/features/status/components/card';
+import Audio from 'flavours/glitch/features/audio';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
+
+export default class MediaContainer extends PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    components: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+    time: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('with-modals--active');
+    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+    this.setState({ media, index });
+  }
+
+  handleOpenVideo = (video, time) => {
+    const media = ImmutableList([video]);
+
+    document.body.classList.add('with-modals--active');
+    document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+
+    this.setState({ media, time });
+  }
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('with-modals--active');
+    document.documentElement.style.marginRight = 0;
+
+    this.setState({ media: null, index: null, time: null });
+  }
+
+  render () {
+    const { locale, components } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(components, (component, i) => {
+            const componentName = component.getAttribute('data-component');
+            const Component = MEDIA_COMPONENTS[componentName];
+            const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+            Object.assign(props, {
+              ...(media   ? { media:   fromJS(media)   } : {}),
+              ...(card    ? { card:    fromJS(card)    } : {}),
+              ...(poll    ? { poll:    fromJS(poll)    } : {}),
+              ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
+
+              ...(componentName === 'Video' ? {
+                onOpenVideo: this.handleOpenVideo,
+              } : {
+                onOpenMedia: this.handleOpenMedia,
+              }),
+            });
+
+            return ReactDOM.createPortal(
+              <Component {...props} key={`media-${i}`} />,
+              component,
+            );
+          })}
+
+          <ModalRoot onClose={this.handleCloseMedia}>
+            {this.state.media && (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index || 0}
+                time={this.state.time}
+                onClose={this.handleCloseMedia}
+              />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js
new file mode 100644
index 000000000..2570cf4a5
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/notification_purge_buttons_container.js
@@ -0,0 +1,49 @@
+//  Package imports.
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+
+//  Our imports.
+import NotificationPurgeButtons from 'flavours/glitch/components/notification_purge_buttons';
+import {
+  deleteMarkedNotifications,
+  enterNotificationClearingMode,
+  markAllNotifications,
+} from 'flavours/glitch/actions/notifications';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const messages = defineMessages({
+  clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
+  clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+
+  onDeleteMarked() {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(deleteMarkedNotifications()),
+    }));
+  },
+
+  onMarkAll() {
+    dispatch(markAllNotifications(true));
+  },
+
+  onMarkNone() {
+    dispatch(markAllNotifications(false));
+  },
+
+  onInvert() {
+    dispatch(markAllNotifications(null));
+  },
+});
+
+const mapStateToProps = state => ({
+  markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));
diff --git a/app/javascript/flavours/glitch/containers/poll_container.js b/app/javascript/flavours/glitch/containers/poll_container.js
new file mode 100644
index 000000000..345351cc6
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/poll_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+
+import Poll from 'flavours/glitch/components/poll';
+import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
+
+const mapDispatchToProps = (dispatch, { pollId }) => ({
+  refresh: debounce(
+    () => {
+      dispatch(fetchPoll(pollId));
+    },
+    1000,
+    { leading: true },
+  ),
+
+  onVote (choices) {
+    dispatch(vote(pollId, choices));
+  },
+});
+
+const mapStateToProps = (state, { pollId }) => ({
+  poll: state.getIn(['polls', pollId]),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Poll);
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
new file mode 100644
index 000000000..7782246a6
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -0,0 +1,260 @@
+import { connect } from 'react-redux';
+import Status from 'flavours/glitch/components/status';
+import { List as ImmutableList } from 'immutable';
+import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  bookmark,
+  unreblog,
+  unfavourite,
+  unbookmark,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { filterEditLink } from 'flavours/glitch/util/backend_links';
+import { showAlertForError } from '../actions/alerts';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Spoilers from '../components/spoilers';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
+  author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
+  matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
+  editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => {
+
+    let status = getStatus(state, props);
+    let reblogStatus = status ? status.get('reblog', null) : null;
+    let account = undefined;
+    let prepend = undefined;
+
+    if (props.featured && status) {
+      account = status.get('account');
+      prepend = 'featured';
+    } else if (reblogStatus !== null && typeof reblogStatus === 'object') {
+      account = status.get('account');
+      status = reblogStatus;
+      prepend = 'reblogged_by';
+    }
+
+    return {
+      containerId : props.containerId || props.id,  //  Should match reblogStatus's id for reblogs
+      status      : status,
+      account     : account || props.account,
+      settings    : state.get('local_settings'),
+      prepend     : prepend || props.prepend,
+      usingPiP    : state.get('picture_in_picture').statusId === props.id,
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+
+      if (state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status));
+    }
+  },
+
+  onReblog (status, e) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog, missingMediaDescription: true }));
+      } else if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+      }
+    });
+  },
+
+  onBookmark (status) {
+    if (status.get('bookmarked')) {
+      dispatch(unbookmark(status));
+    } else {
+      dispatch(bookmark(status));
+    }
+  },
+
+  onModalFavourite (status) {
+    dispatch(favourite(status));
+  },
+
+  onFavourite (status, e) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      if (e.shiftKey || !favouriteModal) {
+        this.onModalFavourite(status);
+      } else {
+        dispatch(openModal('FAVOURITE', { status, onFavourite: this.onModalFavourite }));
+      }
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, options) {
+    dispatch(openModal('VIDEO', { media, options }));
+  },
+
+  onBlock (status) {
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  },
+
+  onUnfilter (status, onConfirm) {
+    dispatch((_, getState) => {
+      let state = getState();
+      const serverSideType = toServerSideType(contextType);
+      const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
+      const searchIndex = status.get('search_index');
+      const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex));
+      dispatch(openModal('CONFIRM', {
+        message: [
+          <FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />,
+          <div className='filtered-status-info'>
+            <Spoilers spoilerText={intl.formatMessage(messages.author)}>
+              <AccountContainer id={status.getIn(['account', 'id'])} />
+            </Spoilers>
+            <Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}>
+              <ul>
+                {matchingFilters.map(filter => (
+                  <li>
+                    {filter.get('phrase')}
+                    {!!filterEditLink && ' '}
+                    {!!filterEditLink && (
+                      <a
+                        target='_blank'
+                        className='filtered-status-edit-link'
+                        title={intl.formatMessage(messages.editFilter)}
+                        href={filterEditLink(filter.get('id'))}
+                      >
+                        <Icon id='pencil' />
+                      </a>
+                    )}
+                  </li>
+                ))}
+              </ul>
+            </Spoilers>
+          </div>
+        ],
+        confirm: intl.formatMessage(messages.unfilterConfirm),
+        onConfirm: onConfirm,
+      }));
+    });
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  deployPictureInPicture (status, type, mediaProps) {
+    dispatch((_, getState) => {
+      if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
+        dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+      }
+    });
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js
new file mode 100644
index 000000000..b61dc8dd7
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/timeline_container.js
@@ -0,0 +1,62 @@
+import React, { Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from 'flavours/glitch/store/configureStore';
+import { hydrateStore } from 'flavours/glitch/actions/store';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
+import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
+import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
+import initialState from 'flavours/glitch/util/initial_state';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const store = configureStore();
+
+if (initialState) {
+  store.dispatch(hydrateStore(initialState));
+}
+
+export default class TimelineContainer extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    hashtag: PropTypes.string,
+    local: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    local: !initialState.settings.known_fediverse,
+  };
+
+  render () {
+    const { locale, hashtag, local } = this.props;
+
+    let timeline;
+
+    if (hashtag) {
+      timeline = <HashtagTimeline hashtag={hashtag} local={local} />;
+    } else {
+      timeline = <PublicTimeline local={local} />;
+    }
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Provider store={store}>
+          <Fragment>
+            {timeline}
+
+            {ReactDOM.createPortal(
+              <ModalContainer />,
+              document.getElementById('modal-container'),
+            )}
+          </Fragment>
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}