Implement quote feature

This commit is contained in:
kyori19 2021-01-02 17:38:40 +09:00
parent 633d175146
commit 2aa6ec14fe
64 changed files with 1068 additions and 278 deletions

View File

@ -8,7 +8,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -7,7 +7,10 @@ class Api::V1::BookmarksController < Api::BaseController
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -7,7 +7,10 @@ class Api::V1::FavouritesController < Api::BaseController
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -30,8 +30,11 @@ class Api::V1::StatusesController < Api::BaseController
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
render json: @context, serializer: REST::ContextSerializer,
relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
def create
@ -46,7 +49,8 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true)
with_rate_limit: true,
quote_id: status_params[:quote_id].presence)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
@ -85,6 +89,7 @@ class Api::V1::StatusesController < Api::BaseController
:spoiler_text,
:visibility,
:scheduled_at,
:quote_id,
media_ids: [],
poll: [
:multiple,

View File

@ -7,10 +7,12 @@ class Api::V1::Timelines::HomeController < Api::BaseController
def show
@statuses = load_statuses
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id),
status: account_home_feed.regenerating? ? 206 : 200
end

View File

@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -6,7 +6,11 @@ class Api::V1::Timelines::PublicController < Api::BaseController
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -6,7 +6,11 @@ class Api::V1::Timelines::TagController < Api::BaseController
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
end
private

View File

@ -1,5 +1,7 @@
import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
import { uniq } from '../utils/uniq';
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
@ -20,6 +22,7 @@ export function fetchBookmarkedStatuses() {
api(getState).get('/api/v1/bookmarks').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchBookmarkedStatusesFail(error));
@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() {
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkedStatusesFail(error));

View File

@ -19,6 +19,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
@ -100,6 +102,23 @@ export function cancelReplyCompose() {
};
};
export function quoteCompose(status, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_QUOTE,
status: status,
});
ensureComposeIsVisible(getState, routerHistory);
};
};
export function cancelQuoteCompose() {
return {
type: COMPOSE_QUOTE_CANCEL,
};
};
export function resetCompose() {
return {
type: COMPOSE_RESET,
@ -147,6 +166,7 @@ export function submitCompose(routerHistory) {
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
quote_id: getState().getIn(['compose', 'quote_from'], null),
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),

View File

@ -1,5 +1,7 @@
import { fetchRelationships } from './accounts';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
import { uniq } from '../utils/uniq';
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
@ -20,6 +22,7 @@ export function fetchFavouritedStatuses() {
api(getState).get('/api/v1/favourites').then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchFavouritedStatusesFail(error));
@ -64,6 +67,7 @@ export function expandFavouritedStatuses() {
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandFavouritedStatusesFail(error));

View File

@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog);
}
if (status.quote && status.quote.id) {
processStatus(status.quote);
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
}

View File

@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.hidden = normalOldStatus.get('hidden');
normalStatus.quote = normalOldStatus.get('quote');
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
@ -69,6 +71,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
if (status.quote) {
normalStatus.quote = normalizeStatus(status.quote, null);
normalStatus.quote_hidden = normalStatus.quote.hidden;
}
}
return normalStatus;

View File

@ -46,7 +46,7 @@ defineMessages({
});
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
const accountIds = notifications.map(item => item.account.id);
if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds));

View File

@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE';
export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const QUOTE_REVEAL = 'QUOTE_REVEAL';
export const QUOTE_HIDE = 'QUOTE_HIDE';
export const REDRAFT = 'REDRAFT';
export function fetchStatusRequest(id, skipLoading) {
@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
isCollapsed,
};
}
export function hideQuote(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: QUOTE_HIDE,
ids,
};
};
export function revealQuote(ids) {
if (!Array.isArray(ids)) {
ids = [ids];
}
return {
type: QUOTE_REVEAL,
ids,
};
};

View File

@ -1,9 +1,11 @@
import { fetchRelationships } from './accounts';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import api, { getLinks } from 'mastodon/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { uniq } from '../utils/uniq';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@ -30,6 +32,7 @@ export function updateTimeline(timeline, status, accept) {
}
dispatch(importFetchedStatus(status));
dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x)));
dispatch({
type: TIMELINE_UPDATE,
@ -102,6 +105,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x))));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
if (timelineId === 'home') {

View File

@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
quote: PropTypes.bool,
};
static defaultProps = {
standalone: false,
quote: false,
};
state = {
@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
}
render () {
const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
const { media, intl, sensitive, height, defaultWidth, standalone, autoplay, quote } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@ -329,6 +331,10 @@ class MediaGallery extends React.PureComponent {
style.height = height;
}
if (quote && style.height) {
style.height /= 2;
}
const size = media.take(4).size;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');

View File

@ -1,4 +1,5 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
export const mapStateToProps = (state, props) => {
let status = props.status;
if (status === null) {
return null;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
if (status.get('quote', null) === null) {
return {
quoteMuted: !!status.get('quote_id', null),
};
}
const id = status.getIn(['quote', 'account', 'id'], null);
return {
quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])),
};
};
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
@ -59,7 +83,61 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => {
const quoteStatus = status.get('quote', null);
if (!quoteStatus) {
return null;
}
const quoteInner = (() => {
const hideUnlisted = quoteStatus.get('visibility') === 'unlisted'
&& ['public', 'community', 'hashtag'].includes(contextType);
if (quoteMuted || hideUnlisted) {
const content = (() => {
if (quoteMuted) {
return (
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
);
}
return (
<button onClick={handleQuoteClick}>
<FormattedMessage id='status.unlisted_quote' defaultMessage='Unlisted quote' />
</button>
);
})();
return (
<div className={classNames('status__content', { 'muted-quote': quoteMuted, 'unlisted-quote': hideUnlisted, 'status__content--with-action': router })}>
{content}
</div>
);
}
return (
<div>
<div className='status__info'>
{identity(quoteStatus, null, null, true)}
</div>
<StatusContent status={quoteStatus} onClick={handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={handleExpandedQuoteToggle} quote />
{media(quoteStatus, true)}
</div>
);
})();
return (
<div
className={classNames('quote-status', `status-${quoteStatus.get('visibility')}`, { muted: muted })}
data-id={quoteStatus.get('id')}
dataurl={quoteStatus.get('url')}
>
{quoteInner}
</div>
);
};
export default @connect(mapStateToProps) @injectIntl
class Status extends ImmutablePureComponent {
static contextTypes = {
@ -85,7 +163,9 @@ class Status extends ImmutablePureComponent {
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
quoteMuted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
@ -101,6 +181,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
contextType: PropTypes.string,
};
// Avoid checking props that are functions (and whose equality will always
@ -112,10 +193,12 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
'quoteMuted',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
statusId: undefined,
};
@ -123,6 +206,7 @@ class Status extends ImmutablePureComponent {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
showQuoteMedia: defaultMediaVisibility(nextProps.status ? nextProps.status.get('quote', null) : null),
statusId: nextProps.status.get('id'),
};
} else {
@ -134,6 +218,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
handleClick = () => {
if (this.props.onClick) {
this.props.onClick();
@ -164,6 +252,15 @@ class Status extends ImmutablePureComponent {
}
}
handleQuoteClick = () => {
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
@ -180,6 +277,10 @@ class Status extends ImmutablePureComponent {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
}
handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this._properStatus());
}
renderLoadingMediaGallery () {
return <div className='media-gallery' style={{ height: '110px' }} />;
}
@ -280,10 +381,9 @@ class Status extends ImmutablePureComponent {
}
render () {
let media = null;
let statusAvatar, prepend, rebloggedByText;
let prepend, rebloggedByText;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props;
let { status, account, ...other } = this.props;
@ -354,11 +454,12 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog');
}
const media = (status, quote = false) => {
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
return <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
return (
<AttachmentList
compact
media={status.get('media_attachments')}
@ -367,7 +468,7 @@ class Status extends ImmutablePureComponent {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer}>
{Component => (
<Component
@ -382,6 +483,7 @@ class Status extends ImmutablePureComponent {
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
quote={quote}
/>
)}
</Bundle>
@ -389,7 +491,7 @@ class Status extends ImmutablePureComponent {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer}>
{Component => (
<Component
@ -405,14 +507,15 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
quote={quote}
/>
)}
</Bundle>
);
} else {
media = (
return (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
@ -422,15 +525,16 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
quote={quote}
/>
)}
</Bundle>
);
}
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
media = (
return (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
@ -438,17 +542,33 @@ class Status extends ImmutablePureComponent {
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
quote={quote}
/>
);
}
return null;
};
const statusAvatar = (status, account, otherAccounts, quote = false) => {
if (otherAccounts && otherAccounts.size > 0) {
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
return <AvatarComposite accounts={otherAccounts} size={quote ? 18 : 48} />;
} else if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={48} />;
return <Avatar account={status.get('account')} size={quote ? 18 : 48} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
return <AvatarOverlay account={status.get('account')} friend={account} />;
}
};
const identity = (status, account, otherAccounts, quote = false) => (
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar(status, account, otherAccounts, quote)}
</div>
<DisplayName account={status.get('account')} others={otherAccounts} />
</a>
);
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
@ -472,18 +592,14 @@ class Status extends ImmutablePureComponent {
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} others={otherAccounts} />
</a>
{identity(status, account, otherAccounts, false)}
</div>
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
{media}
{media(status)}
{quote(status, this.props.muted, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router, contextType)}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
</div>

View File

@ -24,6 +24,8 @@ const messages = defineMessages({
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' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@ -61,6 +63,7 @@ class StatusActionBar extends ImmutablePureComponent {
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
@ -125,6 +128,10 @@ class StatusActionBar extends ImmutablePureComponent {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history);
}
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
}
@ -220,6 +227,14 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
static quoteTitle = (intl, messages, publicStatus) => {
if (publicStatus) {
return intl.formatMessage(messages.quote);
} else {
return intl.formatMessage(messages.cannot_quote);
}
}
render () {
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
@ -322,6 +337,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
<IconButton className='status__action-bar-button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='quote-right' onClick={this.handleQuoteClick} />
{shareButton}

View File

@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon';
import { autoPlayGif } from 'mastodon/initial_state';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
const QUOTE_MAX_HEIGHT = 102; // 20px * 5 (+ 2px padding at the top)
export default class StatusContent extends React.PureComponent {
@ -24,6 +25,7 @@ export default class StatusContent extends React.PureComponent {
onClick: PropTypes.func,
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
quote: PropTypes.bool,
};
state = {
@ -66,7 +68,7 @@ export default class StatusContent extends React.PureComponent {
let collapsed =
this.props.collapsable
&& this.props.onClick
&& node.clientHeight > MAX_HEIGHT
&& node.clientHeight > (this.props.quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT)
&& this.props.status.get('spoiler_text').length === 0;
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
@ -173,7 +175,7 @@ export default class StatusContent extends React.PureComponent {
}
render () {
const { status } = this.props;
const { status, quote } = this.props;
if (status.get('content').length === 0) {
return null;
@ -203,6 +205,12 @@ export default class StatusContent extends React.PureComponent {
</button>
);
if (quote) {
const doc = new DOMParser().parseFromString(content.__html, 'text/html').documentElement;
Array.from(doc.querySelectorAll('br')).forEach(nl => nl.replaceWith(' '));
content.__html = doc.outerHTML;
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';

View File

@ -4,6 +4,7 @@ import Status from '../components/status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../actions/compose';
@ -24,6 +25,8 @@ import {
hideStatus,
revealStatus,
toggleStatusCollapse,
hideQuote,
revealQuote,
} from '../actions/statuses';
import {
unmuteAccount,
@ -49,6 +52,8 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
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?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@ -98,6 +103,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onQuote (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: () => dispatch(quoteCompose(status, router)),
}));
} else {
dispatch(quoteCompose(status, router));
}
});
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
@ -198,6 +219,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
},
onQuoteToggleHidden (status) {
if (status.get('quote_hidden')) {
dispatch(revealQuote(status.get('id')));
} else {
dispatch(hideQuote(status.get('id')));
}
},
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. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,

View File

@ -47,7 +47,9 @@ export default class TimelineContainer extends React.PureComponent {
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<Fragment>
<div className='standalone-timeline'>
{timeline}
</div>
{ReactDOM.createPortal(
<ModalContainer />,

View File

@ -42,6 +42,7 @@ class Audio extends React.PureComponent {
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
quote: PropTypes.bool,
};
state = {
@ -83,7 +84,11 @@ class Audio extends React.PureComponent {
_setDimensions () {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
let height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
if (this.props.quote) {
height /= 2;
}
if (this.props.cacheWidth) {
this.props.cacheWidth(width);

View File

@ -201,6 +201,7 @@ class ComposeForm extends ImmutablePureComponent {
<WarningContainer />
<ReplyIndicatorContainer />
<ReplyIndicatorContainer quote />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
<AutosuggestInput

View File

@ -6,6 +6,7 @@ import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames';
import AttachmentList from 'mastodon/components/attachment_list';
const messages = defineMessages({
@ -23,6 +24,7 @@ class ReplyIndicator extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
quote: PropTypes.bool,
};
handleClick = () => {
@ -37,7 +39,7 @@ class ReplyIndicator extends ImmutablePureComponent {
}
render () {
const { status, intl } = this.props;
const { status, intl, quote } = this.props;
if (!status) {
return null;
@ -46,7 +48,7 @@ class ReplyIndicator extends ImmutablePureComponent {
const content = { __html: status.get('contentHtml') };
return (
<div className='reply-indicator'>
<div className={classNames('reply-indicator', { 'quote-indicator': quote })}>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>

View File

@ -1,13 +1,14 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { cancelReplyCompose, cancelQuoteCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => ({
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
const mapStateToProps = (state, props) => ({
status: getStatus(state, { id: state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']) }),
quote: props.quote,
});
return mapStateToProps;
@ -15,8 +16,8 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelReplyCompose());
onCancel (quote) {
dispatch(quote ? cancelQuoteCompose() : cancelReplyCompose());
},
});

View File

@ -4,10 +4,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import StatusActionBar from 'mastodon/components/status_action_bar';
import classNames from 'classnames';
import { me, boostModal } from 'mastodon/initial_state';
import { defineMessages, injectIntl } from 'react-intl';
import { replyCompose } from 'mastodon/actions/compose';
import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors';
import { openModal } from 'mastodon/actions/modal';
@ -19,9 +20,13 @@ const messages = defineMessages({
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' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
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?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
});
@ -106,6 +111,31 @@ class Footer extends ImmutablePureComponent {
}
};
_performQuote = () => {
const { dispatch, status, onClose } = this.props;
const { router } = this.context;
if (onClose) {
onClose();
}
dispatch(quoteCompose(status, router.history));
}
handleQuoteClick = () => {
const { dispatch, askReplyConfirmation, intl } = this.props;
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: this._performQuote,
}));
} else {
this._performQuote();
}
}
handleOpenClick = e => {
const { router } = this.context;

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import IconButton from '../../../components/icon_button';
import StatusActionBar from '../../../components/status_action_bar';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
@ -18,6 +19,8 @@ const messages = defineMessages({
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' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
more: { id: 'status.more', defaultMessage: 'More' },
@ -56,6 +59,7 @@ class ActionBar extends React.PureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
@ -82,6 +86,10 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
}
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history);
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
@ -276,6 +284,7 @@ class ActionBar extends React.PureComponent {
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} 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>
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@ -68,6 +68,7 @@ export default class Card extends React.PureComponent {
defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func,
sensitive: PropTypes.bool,
quote: PropTypes.bool,
};
static defaultProps = {
@ -184,7 +185,7 @@ export default class Card extends React.PureComponent {
}
render () {
const { card, maxDescription, compact } = this.props;
const { card, maxDescription, compact, quote } = this.props;
const { width, embedded, revealed } = this.state;
if (card === null) {
@ -197,7 +198,11 @@ export default class Card extends React.PureComponent {
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 ratio = card.get('width') / card.get('height');
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
let height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
if (quote && height) {
height /= 2;
}
const description = (
<div className='status-card__content'>

View File

@ -1,8 +1,10 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { mapStateToProps, quote } from '../../../components/status';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
@ -24,7 +26,7 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
export default @connect(mapStateToProps) @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
@ -36,16 +38,20 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onQuoteToggleHidden: PropTypes.func.isRequired,
measureHeight: PropTypes.bool,
onHeightChange: PropTypes.func,
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
quoteMuted: PropTypes.bool,
showMedia: PropTypes.bool,
showQuoteMedia: PropTypes.bool,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
onToggleMediaVisibility: PropTypes.func,
onQuoteToggleMediaVisibility: PropTypes.func,
};
state = {
@ -54,8 +60,9 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
this.context.router.history.push(`/accounts/${id}`);
}
e.stopPropagation();
@ -69,6 +76,19 @@ class DetailedStatus extends ImmutablePureComponent {
this.props.onToggleHidden(this.props.status);
}
handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this.props.status);
}
handleQuoteClick = () => {
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
}
_measureHeight (heightJustChanged) {
if (this.props.measureHeight && this.node) {
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
@ -105,13 +125,12 @@ class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
const { intl, compact, pictureInPicture } = this.props;
const { intl, compact, pictureInPicture, quoteMuted } = this.props;
if (!status) {
return null;
}
let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
@ -121,13 +140,21 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
const identity = (status, _0, _1, quote = false) => (
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={quote ? 18 : 48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
);
const media = (status, quote = false) => {
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder />;
return <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
return (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
@ -137,12 +164,13 @@ class DetailedStatus extends ImmutablePureComponent {
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
height={150}
quote={quote}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
return (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
@ -156,10 +184,11 @@ class DetailedStatus extends ImmutablePureComponent {
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
quote={quote}
/>
);
} else {
media = (
return (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
@ -168,13 +197,24 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
quote={quote}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
return (
<Card
sensitive={status.get('sensitive')}
onOpenMedia={this.props.onOpenMedia}
card={status.get('card', null)}
quote={quote}
/>
);
}
return null;
};
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>;
}
@ -240,14 +280,13 @@ class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
<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>
{identity(status, null, null, false)}
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
{media}
{media(status, false)}
{quote(status, false, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router)}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>

View File

@ -20,6 +20,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
hideQuote,
revealQuote,
} from '../../../actions/statuses';
import { initMuteModal } from '../../../actions/mutes';
import { initBlockModal } from '../../../actions/blocks';
@ -164,6 +166,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onQuoteToggleHidden (status) {
if (status.get('quote_hidden')) {
dispatch(revealQuote(status.get('id')));
} else {
dispatch(hideQuote(status.get('id')));
}
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));

View File

@ -22,6 +22,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@ -31,6 +32,8 @@ import {
deleteStatus,
hideStatus,
revealStatus,
hideQuote,
revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@ -67,6 +70,8 @@ const messages = defineMessages({
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
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?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
});
@ -179,6 +184,7 @@ class Status extends ImmutablePureComponent {
state = {
fullscreen: false,
showMedia: defaultMediaVisibility(this.props.status),
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
loadedStatusId: undefined,
};
@ -197,7 +203,11 @@ class Status extends ImmutablePureComponent {
}
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
this.setState({
showMedia: defaultMediaVisibility(nextProps.status),
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
loadedStatusId: nextProps.status.get('id'),
});
}
}
@ -205,6 +215,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
}
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
handleFavouriteClick = (status) => {
if (status.get('favourited')) {
this.props.dispatch(unfavourite(status));
@ -234,6 +248,19 @@ class Status extends ImmutablePureComponent {
}
}
handleQuoteClick = (status) => {
let { askReplyConfirmation, dispatch, intl } = this.props;
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: () => dispatch(quoteCompose(status, this.context.router.history)),
}));
} else {
dispatch(quoteCompose(status, this.context.router.history));
}
}
handleModalReblog = (status) => {
this.props.dispatch(reblog(status));
}
@ -322,6 +349,14 @@ class Status extends ImmutablePureComponent {
}
}
handleQuoteToggleHidden = (status) => {
if (status.get('quote_hidden')) {
this.props.dispatch(revealQuote(status.get('id')));
} else {
this.props.dispatch(hideQuote(status.get('id')));
}
}
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
@ -552,9 +587,12 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden}
onQuoteToggleHidden={this.handleQuoteToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
showQuoteMedia={this.state.showQuoteMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
pictureInPicture={pictureInPicture}
/>
@ -564,6 +602,7 @@ class Status extends ImmutablePureComponent {
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onQuote={this.handleQuoteClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}
onDirect={this.handleDirectClick}

View File

@ -121,6 +121,7 @@ class Video extends React.PureComponent {
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
quote: PropTypes.bool,
};
static defaultProps = {
@ -521,7 +522,7 @@ class Video extends React.PureComponent {
}
render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash } = this.props;
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, quote } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {};
@ -535,6 +536,11 @@ class Video extends React.PureComponent {
playerStyle.height = height;
}
if (quote && height) {
height /= 2;
playerStyle.height = height;
}
let preload;
if (this.props.currentTime || fullscreen || dragging) {

View File

@ -449,6 +449,14 @@
"defaultMessage": "This post cannot be boosted",
"id": "status.cannot_reblog"
},
{
"defaultMessage": "Quote",
"id": "status.quote"
},
{
"defaultMessage": "This post cannot be quoted",
"id": "status.cannot_quote"
},
{
"defaultMessage": "Favourite",
"id": "status.favourite"
@ -570,6 +578,14 @@
{
"defaultMessage": "{name} boosted",
"id": "status.reblogged_by"
},
{
"defaultMessage": "Muted quote",
"id": "status.muted_quote"
},
{
"defaultMessage": "Unlisted quote",
"id": "status.unlisted_quote"
}
],
"path": "app/javascript/mastodon/components/status.json"
@ -2770,6 +2786,14 @@
"defaultMessage": "Detailed conversation view",
"id": "status.detailed_status"
},
{
"defaultMessage": "Quote",
"id": "confirmations.quote.confirm"
},
{
"defaultMessage": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"id": "confirmations.quote.message"
},
{
"defaultMessage": "Reply",
"id": "confirmations.reply.confirm"

View File

@ -119,6 +119,8 @@
"confirmations.mute.confirm": "Mute",
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.quote.confirm": "Quote",
"confirmations.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
@ -394,6 +396,7 @@
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cannot_quote": "This post cannot be quoted",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to toot",
"status.delete": "Delete",
@ -408,9 +411,11 @@
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.muted_quote": "Muted quote",
"status.open": "Expand this toot",
"status.pin": "Pin on profile",
"status.pinned": "Pinned toot",
"status.quote": "Quote",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",
@ -429,6 +434,7 @@
"status.show_more_all": "Show more for all",
"status.show_thread": "Show thread",
"status.uncached_media_warning": "Not available",
"status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"suggestions.dismiss": "Dismiss suggestion",

View File

@ -119,6 +119,8 @@
"confirmations.mute.confirm": "ミュート",
"confirmations.mute.explanation": "これにより相手のトゥートと返信は見えなくなりますが、相手はあなたをフォローし続けトゥートを見ることができます。",
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
"confirmations.quote.confirm": "引用",
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.redraft.confirm": "削除して下書きに戻す",
"confirmations.redraft.message": "本当にこのトゥートを削除して下書きに戻しますか? このトゥートへのお気に入り登録やブーストは失われ、返信は孤立することになります。",
"confirmations.reply.confirm": "返信",
@ -394,6 +396,7 @@
"status.block": "@{name}さんをブロック",
"status.bookmark": "ブックマーク",
"status.cancel_reblog_private": "ブースト解除",
"status.cannot_quote": "この投稿は引用できません",
"status.cannot_reblog": "この投稿はブーストできません",
"status.copy": "トゥートへのリンクをコピー",
"status.delete": "削除",
@ -408,9 +411,11 @@
"status.more": "もっと見る",
"status.mute": "@{name}さんをミュート",
"status.mute_conversation": "会話をミュート",
"status.muted_quote": "ミュートされた引用",
"status.open": "詳細を表示",
"status.pin": "プロフィールに固定表示",
"status.pinned": "固定されたトゥート",
"status.quote": "引用",
"status.read_more": "もっと見る",
"status.reblog": "ブースト",
"status.reblog_private": "ブースト",
@ -429,6 +434,7 @@
"status.show_more_all": "全て見る",
"status.show_thread": "スレッドを表示",
"status.uncached_media_warning": "利用できません",
"status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除",
"status.unpin": "プロフィールへの固定を解除",
"suggestions.dismiss": "隠す",

View File

@ -4,6 +4,8 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_DIRECT,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
@ -59,6 +61,8 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
quote_from: null,
quote_from_url: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@ -102,6 +106,7 @@ function clearAll(state) {
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
map.set('quote_from', null);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
@ -217,6 +222,17 @@ const expiresInFromExpiresAt = expires_at => {
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
};
const rejectQuoteAltText = html => {
const fragment = domParser.parseFromString(html, 'text/html').documentElement;
const quote_inline = fragment.querySelector('span.quote-inline');
if (quote_inline) {
quote_inline.remove();
}
return fragment.innerHTML;
};
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase();
if (suggestions.length < 4) {
@ -290,9 +306,19 @@ export default function compose(state = initialState, action) {
case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value);
case COMPOSE_REPLY:
case COMPOSE_QUOTE:
return state.withMutations(map => {
if (action.type === COMPOSE_REPLY) {
map.set('in_reply_to', action.status.get('id'));
map.set('quote_from', null);
map.set('quote_from_url', null);
map.set('text', statusToTextMentions(state, action.status));
} else {
map.set('in_reply_to', null);
map.set('quote_from', action.status.get('id'));
map.set('quote_from_url', action.status.get('url'));
map.set('text', '');
}
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
map.set('caretPosition', null);
@ -308,9 +334,12 @@ export default function compose(state = initialState, action) {
}
});
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
return state.withMutations(map => {
map.set('in_reply_to', null);
map.set('quote_from', null);
map.set('quote_from_url', null);
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
@ -399,8 +428,10 @@ export default function compose(state = initialState, action) {
}));
case REDRAFT:
return state.withMutations(map => {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('quote_from', action.status.getIn(['quote', 'id']));
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());

View File

@ -8,6 +8,7 @@ import {
import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_QUOTE,
COMPOSE_DIRECT,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
@ -34,6 +35,7 @@ export default function search(state = initialState, action) {
case SEARCH_SHOW:
return state.set('hidden', false);
case COMPOSE_REPLY:
case COMPOSE_QUOTE:
case COMPOSE_MENTION:
case COMPOSE_DIRECT:
return state.set('hidden', true);

View File

@ -13,6 +13,8 @@ import {
STATUS_REVEAL,
STATUS_HIDE,
STATUS_COLLAPSE,
QUOTE_REVEAL,
QUOTE_HIDE,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@ -75,6 +77,14 @@ export default function statuses(state = initialState, action) {
});
case STATUS_COLLAPSE:
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
case QUOTE_REVEAL:
return state.withMutations(map => {
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
});
case QUOTE_HIDE:
return state.withMutations(map => {
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
});
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
default:

View File

@ -1,6 +1,7 @@
import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
import { me } from '../initial_state';
import {reblogRequest} from '../actions/interactions';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@ -88,22 +89,54 @@ export const makeGetStatus = () => {
[
(state, { id }) => state.getIn(['statuses', id]),
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', 'account'])]),
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', id, 'account']), 'moved'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account']), 'moved'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account']), 'moved'])]),
getFiltersRegex,
],
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
(statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationshipBase, relationshipReblog, relationshipQuote, movedBase, movedReblog, movedQuote, filtersRegex) => {
if (!statusBase) {
return null;
}
accountBase = accountBase.withMutations(map => {
map.set('relationship', relationshipBase);
map.set('moved', movedBase);
});
if (statusReblog) {
accountReblog = accountReblog.withMutations(map => {
map.set('relationship', relationshipReblog);
map.set('moved', movedReblog);
});
statusReblog = statusReblog.set('account', accountReblog);
} else {
statusReblog = null;
}
if (statusQuote) {
accountQuote = accountQuote.withMutations(map => {
map.set('relationship', relationshipQuote);
map.set('moved', movedQuote);
});
statusQuote = statusQuote.set('account', accountQuote);
} else {
statusQuote = null;
}
if (statusReblog && accountReblogQuote) {
statusReblog = statusReblog.setIn(['quote', 'account'], accountReblogQuote);
}
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
return null;
@ -114,6 +147,7 @@ export const makeGetStatus = () => {
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('quote', statusQuote);
map.set('account', accountBase);
map.set('filtered', filtered);
});

View File

@ -0,0 +1 @@
export const uniq = (array) => array.filter((x, i, self) => self.indexOf(x) === i);

View File

@ -291,6 +291,29 @@ function main() {
}
});
});
delegate(document, '.quote-status', 'click', ({ target }) => {
if (target.closest('.status__content__spoiler-link') ||
target.closest('.media-gallery') ||
target.closest('.video-player') ||
target.closest('.audio-player')) {
return false;
}
let url = target.closest('.quote-status').getAttribute('dataurl');
if (target.closest('.status__display-name')) {
url = target.closest('.status__display-name').getAttribute('href');
} else if (target.closest('.status-card')) {
url = target.closest('.status-card').getAttribute('href');
}
if (window.location.hostname === url.split('/')[2].split(':')[0]) {
window.location.href = url;
} else {
window.open(url, 'blank');
}
return false;
});
}
loadPolyfills()

View File

@ -768,6 +768,10 @@
min-height: 23px;
overflow-y: auto;
flex: 0 2 auto;
&.quote-indicator {
background: $success-green;
}
}
.reply-indicator__header {
@ -948,6 +952,10 @@
.status__content.status__content--collapsed {
max-height: 20px * 15; // 15 lines is roughly above 500 characters
.quote-status & {
max-height: 20px * 5;
}
}
.status__content__read-more-button {
@ -996,6 +1004,76 @@
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
.quote-status {
border: solid 1px $ui-base-lighter-color;
border-radius: 4px !important;
padding: 5px !important;
margin-top: 8px;
position: relative;
.muted-quote,
.unlisted-quote button {
color: $dark-text-color;
font-size: 15px;
width: 100%;
border: 0;
padding: 0;
}
.muted-quote {
text-align: center;
cursor: default;
}
.unlisted-quote button {
background-color: transparent;
cursor: pointer;
appearance: none;
}
.status__avatar,
.detailed-status__display-avatar {
height: 18px !important;
width: 18px !important;
position: absolute;
top: 5px !important;
left: 5px !important;
& img {
height: 18px !important;
width: 18px !important;
}
}
.display-name {
padding-left: 20px;
}
.detailed-status__display-name {
margin-bottom: 0;
line-height: unset;
strong,
span {
display: inline;
}
}
.status__content__text {
p {
display: inline;
&::after {
content: ' ';
}
}
}
}
.quote-inline {
display: none;
}
.status__prepend-icon-wrapper {
left: -26px;
position: absolute;
@ -1018,9 +1096,12 @@
padding-left: 68px;
position: relative;
min-height: 54px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: auto;
&:not(.quote-status) {
border-bottom: 1px solid lighten($ui-base-color, 8%);
}
@supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
// Add margin to avoid Edge auto-hiding scrollbar appearing over content.
// On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.

View File

@ -40,7 +40,7 @@
&:last-child {
.detailed-status,
.status,
.status:not(.quote-status),
.load-more {
border-bottom: 0;
border-radius: 0 0 4px 4px;
@ -63,9 +63,18 @@
}
}
.detailed-status .quote-status {
width: 100%;
}
.quote-status {
margin-top: 15px;
cursor: pointer;
}
@media screen and (max-width: 740px) {
.detailed-status,
.status,
.status:not(.quote-status),
.load-more {
border-radius: 0 !important;
}
@ -77,6 +86,10 @@
}
}
.standalone-timeline .quote-status {
cursor: pointer;
}
.button.logo-button {
flex: 0 auto;
font-size: 14px;

View File

@ -77,6 +77,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@mentions = []
@params = {}
process_quote
process_status_params
process_tags
process_audience
@ -117,6 +118,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(4).map(&:id),
poll: process_poll,
quote: @quote,
}
end
end
@ -521,4 +523,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def poll_lock_options
{ redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
end
def process_quote
if (@quote = quote_from_url(@object['quoteUrl']))
/<br><br>RE:\s<\/span><a.*<\/a>/.match(@object['content']) do |m|
@object['content'] = @object['content'].sub(m[0], "</span><span class=\"quote-inline\">#{m[0].sub(/<\/span>/, '')}</span>")
end
end
end
def quote_from_url(url)
return nil if url.nil?
quote = ResolveURLService.new.call(url)
status_from_uri(quote.uri) if quote
rescue
nil
end
end

View File

@ -24,6 +24,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
}.freeze
def self.default_key_transform

View File

@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
elsif value.start_with?('_')
value
else
value.underscore.camelize(:lower)
end

View File

@ -39,11 +39,23 @@ class Formatter
html = encode_and_link_urls(html, linkable_accounts)
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = simple_format(html, {}, sanitize: false)
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
html = html.delete("\n")
html.html_safe # rubocop:disable Rails/OutputSafety
end
def format_in_quote(status, **options)
html = format(status)
return '' if html.empty?
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
html = doc.css('body')[0].inner_html
html.sub!(/^<p>(.+)<\/p>$/, '\1')
html = Sanitize.clean(html).delete("\n").truncate(150)
html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
html.html_safe
end
def reformat(html)
sanitize(html, Sanitize::Config::MASTODON_STRICT)
rescue ArgumentError
@ -192,6 +204,12 @@ class Formatter
end
# rubocop:enable Metrics/BlockNesting
def quotify(html, status)
url = ActivityPub::TagManager.instance.url_for(status.quote)
link = encode_and_link_urls(url)
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>~~~~~~~~~~<br/>[#{link}]</span>\\1")
end
def rewrite(text, entities)
text = text.to_s

View File

@ -31,6 +31,7 @@ class Sanitize
next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
next true if e =~ /^(mention|hashtag)$/ # semantic classes
next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
next true if e =~ /^quote-inline$/ # quote inline classes
end
node['class'] = class_list.join(' ')

View File

@ -21,6 +21,7 @@
# account_id :bigint(8) not null
# application_id :bigint(8)
# in_reply_to_account_id :bigint(8)
# quote_id :bigint(8)
# poll_id :bigint(8)
# deleted_at :datetime
#
@ -55,6 +56,7 @@ class Status < ApplicationRecord
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
@ -63,6 +65,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
@ -77,6 +80,7 @@ class Status < ApplicationRecord
validates_with DisallowedHashtagsValidator
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
accepts_nested_attributes_for :poll
@ -165,6 +169,14 @@ class Status < ApplicationRecord
!reblog_of_id.nil?
end
def quote?
!quote_id.nil? && quote
end
def quote_visibility
quote&.visibility
end
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end
@ -225,7 +237,12 @@ class Status < ApplicationRecord
fields = [spoiler_text, text]
fields += preloadable_poll.options unless preloadable_poll.nil?
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
quote_fields = []
quote_fields += [quote.spoiler_text, quote.text] if quote?
quote_fields += quote.preloadable_poll.options unless quote&.preloadable_poll.nil?
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain) +
CustomEmoji.from_text(quote_fields.join(' '), quote&.account&.domain)
end
def replies_count

View File

@ -9,6 +9,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:atom_uri, :in_reply_to_atom_uri,
:conversation
attribute :quote_url, if: -> { object.quote? }
attribute :misskey_quote, key: :_misskey_quote, if: -> { object.quote? }
attribute :misskey_content, key: :_misskey_content, if: -> { object.quote? }
attribute :content
attribute :content_map, if: :language?
@ -125,6 +128,16 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
def quote_url
ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
end
alias misskey_quote quote_url
def misskey_content
object.text if object.quote?
end
def local?
object.account.local?
end

View File

@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :short_description, :description, :email,
:version, :urls, :stats, :thumbnail,
:languages, :registrations, :approval_required, :invites_enabled
:languages, :registrations, :approval_required, :invites_enabled,
:feature_quote
has_one :contact_account, serializer: REST::AccountSerializer
@ -67,6 +68,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
Setting.min_invite_role == 'user'
end
def feature_quote
true
end
private
def instance_presenter

View File

@ -15,6 +15,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :content, unless: :source_requested?
attribute :text, if: :source_requested?
attribute :quote_id, if: -> { object.quote? }
belongs_to :reblog, serializer: REST::StatusSerializer
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer
@ -39,6 +41,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.in_reply_to_account_id&.to_s
end
def quote_id
object.quote_id.to_s
end
def current_user?
!current_user.nil?
end
@ -167,3 +173,26 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
end
class REST::NestedQuoteSerializer < REST::StatusSerializer
attribute :quote do
nil
end
attribute :quote_muted, if: :current_user?
def quote_muted
if instance_options && instance_options[:account_relationships]
!!instance_options[:account_relationships].muting[object.account_id] ||
instance_options[:account_relationships].blocking[object.account_id] ||
instance_options[:account_relationships].blocked_by[object.account_id] ||
instance_options[:account_relationships].domain_blocking[object.account_id]
else
current_user.account.muting?(object.account) || object.account.blocking?(current_user.account) || current_user.account.blocking?(object.account) || current_user.account.domain_blocking?(object.account.domain)
end
end
end
class REST::StatusSerializer < ActiveModel::Serializer
belongs_to :quote, serializer: REST::NestedQuoteSerializer
end

View File

@ -66,7 +66,7 @@ class FetchLinkCardService < BaseService
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
else
html = Nokogiri::HTML(@status.text)
links = html.css('a')
links = html.css(':not(.quote-inline) > a')
urls = links.map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.compact.map(&:normalize).compact
end

View File

@ -20,17 +20,20 @@ class PostStatusService < BaseService
# @option [Doorkeeper::Application] :application
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
# @option [Integer] :quote_id
# @return [Status]
def call(account, options = {})
@account = account
@options = options
@text = @options[:text] || ''
@in_reply_to = @options[:thread]
@quote_id = @options[:quote_id]
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
validate_media!
preprocess_attributes!
preprocess_quote!
if scheduled?
schedule_status!
@ -47,6 +50,19 @@ class PostStatusService < BaseService
private
def status_from_uri(uri)
ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
end
def quote_from_url(url)
return nil if url.nil?
quote = ResolveURLService.new.call(url)
status_from_uri(quote.uri) if quote
rescue
nil
end
def preprocess_attributes!
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@ -54,10 +70,21 @@ class PostStatusService < BaseService
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
@scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past?
if @quote_id.nil? && (link = @text&.match(/\n\[?(#{FetchLinkCardService::URL_PATTERN})\]?$/))
@text = @text&.lines(chomp: true)[0...-1].join("\n") if (@quote_id = quote_from_url(link[1])&.id)
end
rescue ArgumentError
raise ActiveRecord::RecordInvalid
end
def preprocess_quote!
if @quote_id.present?
quote = Status.find(@quote_id)
@quote_id = quote.reblog_of_id.to_s if quote.reblog?
end
end
def process_status!
# The following transaction block is needed to wrap the UPDATEs to
# the media attachments when the status is created
@ -164,6 +191,7 @@ class PostStatusService < BaseService
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
application: @options[:application],
rate_limit: @options[:with_rate_limit],
quote_id: @quote_id,
}.compact
end

View File

@ -0,0 +1,20 @@
:ruby
detailed ||= false
inline ||= false
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(author), class: "#{ detailed ? 'detailed-' : '' }status__display-name u-url", target: stream_link_target, rel: "noopener #{ (detailed || inline) ? '' : 'noreferrer' }" do
%div{ class: "#{ detailed ? 'detailed-' : '' }status__#{ detailed ? 'display-' : '' }avatar" }
%div
- if current_account&.user&.setting_auto_play_gif || autoplay
= image_tag author.avatar_original_url, alt: '', class: 'u-photo account__avatar'
- else
= image_tag author.avatar_static_url, alt: '', class: 'u-photo account__avatar'
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(author, custom_emojify: true, autoplay: autoplay)
%span.display-name__account
= acct(author)
- unless inline
= fa_icon('lock') if author.locked?

View File

@ -1,45 +1,14 @@
.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
.detailed-status__display-avatar
- if current_account&.user&.setting_auto_play_gif || autoplay
= image_tag status.account.avatar_original_url, alt: '', class: 'account__avatar u-photo'
- else
= image_tag status.account.avatar_static_url, alt: '', class: 'account__avatar u-photo'
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
= render 'statuses/author', author: status.account, autoplay: autoplay, detailed: true
= account_action_button(status.account)
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- if status.preloadable_poll
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
= render 'statuses/text', status: status, autoplay: autoplay
- if !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
- if status.quote?
= render 'statuses/quote', status: status.quote, autoplay: autoplay
= render 'statuses/media', status: status, autoplay: autoplay, detailed: true
.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@ -0,0 +1,18 @@
:ruby
detailed ||= false
quote ||= false
- if !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: detailed ? 670 : 610, height: detailed ? 380 : 343, detailed: detailed, inline: true, quote: quote, alt: video.description do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: detailed ? 670 : 610, height: detailed ? 380 : 343, quote: quote, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: detailed ? 380 : 343, sensitive: sensitized?(status, current_account), standalone: detailed, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: quote do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true

View File

@ -0,0 +1,6 @@
.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
= render 'statuses/author', author: status.account, autoplay: autoplay, inline: true
= render 'statuses/text', status: status, autoplay: autoplay
= render 'statuses/media', status: status, autoplay: autoplay, quote: true

View File

@ -9,46 +9,14 @@
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
%data.dt-published{ value: status.created_at.to_time.iso8601 }
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
.status__avatar
%div
- if current_account&.user&.setting_auto_play_gif || autoplay
= image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
- else
= image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
= ' '
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- if status.preloadable_poll
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
= render 'statuses/author', author: status.account, autoplay: autoplay
- if !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 610, height: 343, inline: true, alt: video.description do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.preview_card
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
= render 'statuses/text', status: status, autoplay: autoplay
- if status.quote?
= render 'statuses/quote', status: status.quote, autoplay: autoplay
= render 'statuses/media', status: status, autoplay: autoplay
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id && !hide_show_thread
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do

View File

@ -0,0 +1,10 @@
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
- if status.preloadable_poll
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }

View File

@ -0,0 +1,5 @@
class AddQuoteIdToStatuses < ActiveRecord::Migration[5.1]
def change
add_column :statuses, :quote_id, :bigint, null: true, default: nil
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddIndexToStatusesQuoteId < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def change
add_index :statuses, :quote_id, algorithm: :concurrently
end
end

View File

@ -803,6 +803,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
t.bigint "account_id", null: false
t.bigint "application_id"
t.bigint "in_reply_to_account_id"
t.bigint "quote_id"
t.bigint "poll_id"
t.datetime "deleted_at"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
@ -810,6 +811,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
t.index ["quote_id"], name: "index_statuses_on_quote_id"
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
t.index ["uri"], name: "index_statuses_on_uri", unique: true
end