about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/status/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/status/components')
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.jsx231
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.jsx282
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.jsx339
3 files changed, 852 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
new file mode 100644
index 000000000..d5ab730d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx
@@ -0,0 +1,231 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me } from 'flavours/glitch/initial_state';
+import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
+import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  edit: { id: 'status.edit', defaultMessage: 'Edit' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+  more: { id: 'status.more', defaultMessage: 'More' },
+  mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  block: { id: 'status.block', defaultMessage: 'Block @{name}' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
+  admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
+  copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+  openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
+});
+
+class ActionBar extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+    identity: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReply: PropTypes.func.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onFavourite: PropTypes.func.isRequired,
+    onBookmark: PropTypes.func.isRequired,
+    onMute: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onBlock: PropTypes.func,
+    onDelete: PropTypes.func.isRequired,
+    onEdit: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReport: PropTypes.func,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleReplyClick = () => {
+    this.props.onReply(this.props.status);
+  };
+
+  handleReblogClick = (e) => {
+    this.props.onReblog(this.props.status, e);
+  };
+
+  handleFavouriteClick = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  };
+
+  handleBookmarkClick = (e) => {
+    this.props.onBookmark(this.props.status, e);
+  };
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history);
+  };
+
+  handleRedraftClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history, true);
+  };
+
+  handleEditClick = () => {
+    this.props.onEdit(this.props.status, this.context.router.history);
+  };
+
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleMentionClick = () => {
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  };
+
+  handleMuteClick = () => {
+    this.props.onMute(this.props.status.get('account'));
+  };
+
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  };
+
+  handleBlockClick = () => {
+    this.props.onBlock(this.props.status);
+  };
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  };
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  };
+
+  handleShare = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  };
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  };
+
+  handleCopy = () => {
+    const url = this.props.status.get('url');
+    navigator.clipboard.writeText(url);
+  };
+
+  render () {
+    const { status, intl } = this.props;
+    const { signedIn, permissions } = this.context.identity;
+
+    const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
+    const mutingConversation = status.get('muted');
+    const writtenByMe        = status.getIn(['account', 'id']) === me;
+    const isRemote           = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
+
+    let menu = [];
+
+    if (publicStatus) {
+      if (isRemote) {
+        menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
+    }
+
+    if (writtenByMe) {
+      if (pinnableStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+        menu.push(null);
+      }
+
+      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
+      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 (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
+        menu.push(null);
+        if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
+          if (accountAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
+          }
+          if (statusAdminLink !== undefined) {
+            menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
+          }
+        }
+        if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
+          const domain = status.getIn(['account', 'acct']).split('@')[1];
+          menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
+        }
+      }
+    }
+
+    const shareButton = ('share' in navigator) && publicStatus && (
+      <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
+    );
+
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let reblogTitle;
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    return (
+      <div className='detailed-status__action-bar'>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
+        {shareButton}
+        <div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
+
+        <div className='detailed-status__action-bar-dropdown'>
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/flavours/glitch/features/status/components/card.jsx b/app/javascript/flavours/glitch/features/status/components/card.jsx
new file mode 100644
index 000000000..359dbbc20
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/card.jsx
@@ -0,0 +1,282 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Immutable from 'immutable';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import punycode from 'punycode';
+import classnames from 'classnames';
+import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
+import Icon from 'flavours/glitch/components/icon';
+import { useBlurhash } from 'flavours/glitch/initial_state';
+import Blurhash from 'flavours/glitch/components/blurhash';
+import { debounce } from 'lodash';
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const trim = (text, len) => {
+  const cut = text.indexOf(' ', len);
+
+  if (cut === -1) {
+    return text;
+  }
+
+  return text.slice(0, cut) + (text.length > len ? '…' : '');
+};
+
+const domParser = new DOMParser();
+
+const addAutoPlay = html => {
+  const document = domParser.parseFromString(html, 'text/html').documentElement;
+  const iframe = document.querySelector('iframe');
+
+  if (iframe) {
+    if (iframe.src.indexOf('?') !== -1) {
+      iframe.src += '&';
+    } else {
+      iframe.src += '?';
+    }
+
+    iframe.src += 'autoplay=1&auto_play=1';
+
+    // DOM parser creates html/body elements around original HTML fragment,
+    // so we need to get innerHTML out of the body and not the entire document
+    return document.querySelector('body').innerHTML;
+  }
+
+  return html;
+};
+
+export default class Card extends React.PureComponent {
+
+  static propTypes = {
+    card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+    onOpenMedia: PropTypes.func.isRequired,
+    compact: PropTypes.bool,
+    defaultWidth: PropTypes.number,
+    cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
+    compact: false,
+  };
+
+  state = {
+    width: this.props.defaultWidth || 280,
+    previewLoaded: false,
+    embedded: false,
+    revealed: !this.props.sensitive,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!Immutable.is(this.props.card, nextProps.card)) {
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  _setDimensions () {
+    const width = this.node.offsetWidth;
+
+    if (this.props.cacheWidth) {
+      this.props.cacheWidth(width);
+    }
+
+    this.setState({ width });
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  handlePhotoClick = () => {
+    const { card, onOpenMedia } = this.props;
+
+    onOpenMedia(
+      Immutable.fromJS([
+        {
+          type: 'image',
+          url: card.get('embed_url'),
+          description: card.get('title'),
+          meta: {
+            original: {
+              width: card.get('width'),
+              height: card.get('height'),
+            },
+          },
+        },
+      ]),
+      0,
+    );
+  };
+
+  handleEmbedClick = () => {
+    const { card } = this.props;
+
+    if (card.get('type') === 'photo') {
+      this.handlePhotoClick();
+    } else {
+      this.setState({ embedded: true });
+    }
+  };
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  };
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  };
+
+  handleReveal = e => {
+    e.preventDefault();
+    e.stopPropagation();
+    this.setState({ revealed: true });
+  };
+
+  renderVideo () {
+    const { card }  = this.props;
+    const content   = { __html: addAutoPlay(card.get('html')) };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = width / ratio;
+
+    return (
+      <div
+        ref={this.setRef}
+        className='status-card__image status-card-video'
+        dangerouslySetInnerHTML={content}
+        style={{ height }}
+      />
+    );
+  }
+
+  render () {
+    const { card, maxDescription, compact, defaultWidth } = this.props;
+    const { width, embedded, revealed } = this.state;
+
+    if (card === null) {
+      return null;
+    }
+
+    const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
+    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+    const interactive = card.get('type') !== 'link';
+    const className   = classnames('status-card', { horizontal, compact, interactive });
+    const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
+    const language    = card.get('language') || '';
+    const ratio       = card.get('width') / card.get('height');
+    const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+    const description = (
+      <div className='status-card__content' lang={language}>
+        {title}
+        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
+        <span className='status-card__host'>{provider}</span>
+      </div>
+    );
+
+    let embed     = '';
+    let canvas = (
+      <Blurhash
+        className={classnames('status-card__image-preview', {
+          'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
+        })}
+        hash={card.get('blurhash')}
+        dummy={!useBlurhash}
+      />
+    );
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
+
+    if (interactive) {
+      if (embedded) {
+        embed = this.renderVideo();
+      } else {
+        let iconVariant = 'play';
+
+        if (card.get('type') === 'photo') {
+          iconVariant = 'search-plus';
+        }
+
+        embed = (
+          <div className='status-card__image'>
+            {canvas}
+            {thumbnail}
+
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
+              </div>
+            )}
+            {!revealed && spoilerButton}
+          </div>
+        );
+      }
+
+      return (
+        <div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
+          {embed}
+          {!compact && description}
+        </div>
+      );
+    } else if (card.get('image')) {
+      embed = (
+        <div className='status-card__image'>
+          {canvas}
+          {thumbnail}
+        </div>
+      );
+    } else {
+      embed = (
+        <div className='status-card__image'>
+          <Icon id='file-text' />
+        </div>
+      );
+    }
+
+    return (
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
+        {embed}
+        {description}
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
new file mode 100644
index 000000000..cfe6c965e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx
@@ -0,0 +1,339 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import StatusContent from 'flavours/glitch/components/status_content';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { injectIntl, FormattedDate } from 'react-intl';
+import Card from './card';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
+import classNames from 'classnames';
+import PollContainer from 'flavours/glitch/containers/poll_container';
+import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
+import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
+
+class DetailedStatus extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    settings: ImmutablePropTypes.map.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+    onOpenVideo: PropTypes.func.isRequired,
+    onToggleHidden: PropTypes.func,
+    onTranslate: PropTypes.func.isRequired,
+    expanded: PropTypes.bool,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+    showMedia: PropTypes.bool,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
+    onToggleMediaVisibility: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    height: null,
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
+    }
+
+    e.stopPropagation();
+  };
+
+  parseClick = (e, destination) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = { ...this.context.router.history.location.state };
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(destination, state);
+    }
+
+    e.stopPropagation();
+  };
+
+  handleOpenVideo = (options) => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
+  };
+
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  };
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleChildUpdate = () => {
+    this._measureHeight();
+  };
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  };
+
+  handleTranslate = () => {
+    const { onTranslate, status } = this.props;
+    onTranslate(status);
+  };
+
+  render () {
+    const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+    const { expanded, onToggleHidden, settings, pictureInPicture, intl } = this.props;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
+    let favouriteLink = '';
+    let edited = '';
+
+    //  Depending on user settings, some media are considered as parts of the
+    //  contents (affected by CW) while other will be displayed outside of the
+    //  CW.
+    let contentMedia = [];
+    let contentMediaIcons = [];
+    let extraMedia = [];
+    let extraMediaIcons = [];
+    let media = contentMedia;
+    let mediaIcons = contentMediaIcons;
+
+    if (settings.getIn(['content_warnings', 'media_outside'])) {
+      media = extraMedia;
+      mediaIcons = extraMediaIcons;
+    }
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
+
+    if (pictureInPicture.get('inUse')) {
+      media.push(<PictureInPicturePlaceholder />);
+      mediaIcons.push('video-camera');
+    } else if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+        media.push(<AttachmentList media={status.get('media_attachments')} />);
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media.push(
+          <Audio
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+            poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
+            backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
+            foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
+            accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
+            sensitive={status.get('sensitive')}
+            visible={this.props.showMedia}
+            blurhash={attachment.get('blurhash')}
+            height={150}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('music');
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        const attachment = status.getIn(['media_attachments', 0]);
+        media.push(
+          <Video
+            preview={attachment.get('preview_url')}
+            frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
+            blurhash={attachment.get('blurhash')}
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            lang={status.get('language')}
+            inline
+            sensitive={status.get('sensitive')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            preventPlayback={!expanded}
+            onOpenVideo={this.handleOpenVideo}
+            autoplay
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('video-camera');
+      } else {
+        media.push(
+          <MediaGallery
+            standalone
+            sensitive={status.get('sensitive')}
+            media={status.get('media_attachments')}
+            lang={status.get('language')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            hidden={!expanded}
+            onOpenMedia={this.props.onOpenMedia}
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />,
+        );
+        mediaIcons.push('picture-o');
+      }
+    } else if (status.get('card')) {
+      media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
+      mediaIcons.push('link');
+    }
+
+    if (status.get('poll')) {
+      contentMedia.push(<PollContainer pollId={status.get('poll')} lang={status.get('language')} />);
+      contentMediaIcons.push('tasks');
+    }
+
+    if (status.get('application')) {
+      applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
+    }
+
+    const visibilityLink = <React.Fragment> · <VisibilityIcon visibility={status.get('visibility')} /></React.Fragment>;
+
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (!['unlisted', 'public'].includes(status.get('visibility'))) {
+      reblogLink = null;
+    } else if (this.context.router) {
+      reblogLink = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </Link>
+        </React.Fragment>
+      );
+    } else {
+      reblogLink = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+            <Icon id={reblogIcon} />
+            <span className='detailed-status__reblogs'>
+              <AnimatedNumber value={status.get('reblogs_count')} />
+            </span>
+          </a>
+        </React.Fragment>
+      );
+    }
+
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </a>
+      );
+    }
+
+    if (status.get('edited_at')) {
+      edited = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
+        </React.Fragment>
+      );
+    }
+
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
+            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent
+            status={status}
+            media={contentMedia}
+            extraMedia={extraMedia}
+            mediaIcons={contentMediaIcons}
+            expanded={expanded}
+            collapsed={false}
+            onExpandedToggle={onToggleHidden}
+            onTranslate={this.handleTranslate}
+            parseClick={this.parseClick}
+            onUpdate={this.handleChildUpdate}
+            tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
+            disabled
+          />
+
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
+              <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+            </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(DetailedStatus);