Include preview cards in status entity in REST API (#9120)
* Include preview cards in status entity in REST API * Display preview card in-stream * Improve in-stream display of preview cards
This commit is contained in:
		
							parent
							
								
									f435af86e7
								
							
						
					
					
						commit
						cf2ab9c394
					
				
					 10 changed files with 61 additions and 11 deletions
				
			
		| 
						 | 
					@ -126,6 +126,7 @@ class ApplicationController < ActionController::Base
 | 
				
			||||||
  def respond_with_error(code)
 | 
					  def respond_with_error(code)
 | 
				
			||||||
    respond_to do |format|
 | 
					    respond_to do |format|
 | 
				
			||||||
      format.any  { head code }
 | 
					      format.any  { head code }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      format.html do
 | 
					      format.html do
 | 
				
			||||||
        set_locale
 | 
					        set_locale
 | 
				
			||||||
        render "errors/#{code}", layout: 'error', status: code
 | 
					        render "errors/#{code}", layout: 'error', status: code
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,7 @@ import DisplayName from './display_name';
 | 
				
			||||||
import StatusContent from './status_content';
 | 
					import StatusContent from './status_content';
 | 
				
			||||||
import StatusActionBar from './status_action_bar';
 | 
					import StatusActionBar from './status_action_bar';
 | 
				
			||||||
import AttachmentList from './attachment_list';
 | 
					import AttachmentList from './attachment_list';
 | 
				
			||||||
 | 
					import Card from '../features/status/components/card';
 | 
				
			||||||
import { injectIntl, FormattedMessage } from 'react-intl';
 | 
					import { injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
 | 
					import { MediaGallery, Video } from '../features/ui/util/async-components';
 | 
				
			||||||
| 
						 | 
					@ -256,6 +257,14 @@ class Status extends ImmutablePureComponent {
 | 
				
			||||||
          </Bundle>
 | 
					          </Bundle>
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					    } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
 | 
				
			||||||
 | 
					      media = (
 | 
				
			||||||
 | 
					        <Card
 | 
				
			||||||
 | 
					          onOpenMedia={this.props.onOpenMedia}
 | 
				
			||||||
 | 
					          card={status.get('card')}
 | 
				
			||||||
 | 
					          compact
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (otherAccounts) {
 | 
					    if (otherAccounts) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -59,10 +59,12 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
    card: ImmutablePropTypes.map,
 | 
					    card: ImmutablePropTypes.map,
 | 
				
			||||||
    maxDescription: PropTypes.number,
 | 
					    maxDescription: PropTypes.number,
 | 
				
			||||||
    onOpenMedia: PropTypes.func.isRequired,
 | 
					    onOpenMedia: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    compact: PropTypes.boolean,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
    maxDescription: 50,
 | 
					    maxDescription: 50,
 | 
				
			||||||
 | 
					    compact: false,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
| 
						 | 
					@ -131,25 +133,25 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { card, maxDescription } = this.props;
 | 
					    const { card, maxDescription, compact } = this.props;
 | 
				
			||||||
    const { width, embedded }      = this.state;
 | 
					    const { width, embedded } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (card === null) {
 | 
					    if (card === null) {
 | 
				
			||||||
      return null;
 | 
					      return null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
 | 
					    const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
 | 
				
			||||||
    const horizontal  = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link';
 | 
					    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
 | 
				
			||||||
    const className   = classnames('status-card', { horizontal });
 | 
					 | 
				
			||||||
    const interactive = card.get('type') !== 'link';
 | 
					    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' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
 | 
					    const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
 | 
				
			||||||
    const ratio       = card.get('width') / card.get('height');
 | 
					    const ratio       = compact ? 16 / 9 : card.get('width') / card.get('height');
 | 
				
			||||||
    const height      = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 | 
					    const height      = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const description = (
 | 
					    const description = (
 | 
				
			||||||
      <div className='status-card__content'>
 | 
					      <div className='status-card__content'>
 | 
				
			||||||
        {title}
 | 
					        {title}
 | 
				
			||||||
        {!horizontal && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
 | 
					        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
 | 
				
			||||||
        <span className='status-card__host'>{provider}</span>
 | 
					        <span className='status-card__host'>{provider}</span>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
| 
						 | 
					@ -174,7 +176,7 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
            <div className='status-card__actions'>
 | 
					            <div className='status-card__actions'>
 | 
				
			||||||
              <div>
 | 
					              <div>
 | 
				
			||||||
                <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button>
 | 
					                <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button>
 | 
				
			||||||
                <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>
 | 
					                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a>}
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
| 
						 | 
					@ -184,7 +186,7 @@ export default class Card extends React.PureComponent {
 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
        <div className={className} ref={this.setRef}>
 | 
					        <div className={className} ref={this.setRef}>
 | 
				
			||||||
          {embed}
 | 
					          {embed}
 | 
				
			||||||
          {description}
 | 
					          {!compact && description}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    } else if (card.get('image')) {
 | 
					    } else if (card.get('image')) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@ import { connect } from 'react-redux';
 | 
				
			||||||
import Card from '../components/card';
 | 
					import Card from '../components/card';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, { statusId }) => ({
 | 
					const mapStateToProps = (state, { statusId }) => ({
 | 
				
			||||||
  card: state.getIn(['cards', statusId], null),
 | 
					  card: state.getIn(['statuses', statusId, 'card'], null),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default connect(mapStateToProps)(Card);
 | 
					export default connect(mapStateToProps)(Card);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,7 +14,6 @@ import relationships from './relationships';
 | 
				
			||||||
import settings from './settings';
 | 
					import settings from './settings';
 | 
				
			||||||
import push_notifications from './push_notifications';
 | 
					import push_notifications from './push_notifications';
 | 
				
			||||||
import status_lists from './status_lists';
 | 
					import status_lists from './status_lists';
 | 
				
			||||||
import cards from './cards';
 | 
					 | 
				
			||||||
import mutes from './mutes';
 | 
					import mutes from './mutes';
 | 
				
			||||||
import reports from './reports';
 | 
					import reports from './reports';
 | 
				
			||||||
import contexts from './contexts';
 | 
					import contexts from './contexts';
 | 
				
			||||||
| 
						 | 
					@ -46,7 +45,6 @@ const reducers = {
 | 
				
			||||||
  relationships,
 | 
					  relationships,
 | 
				
			||||||
  settings,
 | 
					  settings,
 | 
				
			||||||
  push_notifications,
 | 
					  push_notifications,
 | 
				
			||||||
  cards,
 | 
					 | 
				
			||||||
  mutes,
 | 
					  mutes,
 | 
				
			||||||
  reports,
 | 
					  reports,
 | 
				
			||||||
  contexts,
 | 
					  contexts,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,7 @@ import {
 | 
				
			||||||
  STATUS_REVEAL,
 | 
					  STATUS_REVEAL,
 | 
				
			||||||
  STATUS_HIDE,
 | 
					  STATUS_HIDE,
 | 
				
			||||||
} from '../actions/statuses';
 | 
					} from '../actions/statuses';
 | 
				
			||||||
 | 
					import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
 | 
				
			||||||
import { TIMELINE_DELETE } from '../actions/timelines';
 | 
					import { TIMELINE_DELETE } from '../actions/timelines';
 | 
				
			||||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
 | 
					import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
 | 
				
			||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
 | 
					import { Map as ImmutableMap, fromJS } from 'immutable';
 | 
				
			||||||
| 
						 | 
					@ -65,6 +66,8 @@ export default function statuses(state = initialState, action) {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  case TIMELINE_DELETE:
 | 
					  case TIMELINE_DELETE:
 | 
				
			||||||
    return deleteStatus(state, action.id, action.references);
 | 
					    return deleteStatus(state, action.id, action.references);
 | 
				
			||||||
 | 
					  case STATUS_CARD_FETCH_SUCCESS:
 | 
				
			||||||
 | 
					    return state.setIn([action.id, 'card'], fromJS(action.card));
 | 
				
			||||||
  default:
 | 
					  default:
 | 
				
			||||||
    return state;
 | 
					    return state;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2560,6 +2560,9 @@ a.status-card {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  margin-top: 5px;
 | 
					  margin-top: 5px;
 | 
				
			||||||
  font-size: 13px;
 | 
					  font-size: 13px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  text-overflow: ellipsis;
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.status-card__image {
 | 
					.status-card__image {
 | 
				
			||||||
| 
						 | 
					@ -2584,6 +2587,31 @@ a.status-card {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.status-card.compact {
 | 
				
			||||||
 | 
					  border-color: lighten($ui-base-color, 4%);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &.interactive {
 | 
				
			||||||
 | 
					    border: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .status-card__content {
 | 
				
			||||||
 | 
					    padding: 8px;
 | 
				
			||||||
 | 
					    padding-top: 10px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .status-card__title {
 | 
				
			||||||
 | 
					    white-space: nowrap;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .status-card__image {
 | 
				
			||||||
 | 
					    flex: 0 0 60px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					a.status-card.compact:hover {
 | 
				
			||||||
 | 
					  background-color: lighten($ui-base-color, 4%);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.status-card__image-image {
 | 
					.status-card__image-image {
 | 
				
			||||||
  border-radius: 4px 0 0 4px;
 | 
					  border-radius: 4px 0 0 4px;
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -89,6 +89,7 @@ class Status < ApplicationRecord
 | 
				
			||||||
                   :conversation,
 | 
					                   :conversation,
 | 
				
			||||||
                   :status_stat,
 | 
					                   :status_stat,
 | 
				
			||||||
                   :tags,
 | 
					                   :tags,
 | 
				
			||||||
 | 
					                   :preview_cards,
 | 
				
			||||||
                   :stream_entry,
 | 
					                   :stream_entry,
 | 
				
			||||||
                   active_mentions: :account,
 | 
					                   active_mentions: :account,
 | 
				
			||||||
                   reblog: [
 | 
					                   reblog: [
 | 
				
			||||||
| 
						 | 
					@ -96,6 +97,7 @@ class Status < ApplicationRecord
 | 
				
			||||||
                     :application,
 | 
					                     :application,
 | 
				
			||||||
                     :stream_entry,
 | 
					                     :stream_entry,
 | 
				
			||||||
                     :tags,
 | 
					                     :tags,
 | 
				
			||||||
 | 
					                     :preview_cards,
 | 
				
			||||||
                     :media_attachments,
 | 
					                     :media_attachments,
 | 
				
			||||||
                     :conversation,
 | 
					                     :conversation,
 | 
				
			||||||
                     :status_stat,
 | 
					                     :status_stat,
 | 
				
			||||||
| 
						 | 
					@ -163,6 +165,10 @@ class Status < ApplicationRecord
 | 
				
			||||||
    reblog
 | 
					    reblog
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def preview_card
 | 
				
			||||||
 | 
					    preview_cards.first
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def title
 | 
					  def title
 | 
				
			||||||
    if destroyed?
 | 
					    if destroyed?
 | 
				
			||||||
      "#{account.acct} deleted status"
 | 
					      "#{account.acct} deleted status"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,6 +20,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
 | 
				
			||||||
  has_many :tags
 | 
					  has_many :tags
 | 
				
			||||||
  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
					  has_many :emojis, serializer: REST::CustomEmojiSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def id
 | 
					  def id
 | 
				
			||||||
    object.id.to_s
 | 
					    object.id.to_s
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -63,6 +63,7 @@ class FetchLinkCardService < BaseService
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def attach_card
 | 
					  def attach_card
 | 
				
			||||||
    @status.preview_cards << @card
 | 
					    @status.preview_cards << @card
 | 
				
			||||||
 | 
					    Rails.cache.delete(@status)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def parse_urls
 | 
					  def parse_urls
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue