Merge remote-tracking branch 'accelforce/custom/quote' into features/v4.1.2

This commit is contained in:
YoheiZuho 2023-06-30 20:52:31 +09:00
commit 1b02b4bfde
63 changed files with 1069 additions and 276 deletions

View File

@ -7,8 +7,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
@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)
end
private

View File

@ -6,8 +6,11 @@ class Api::V1::BookmarksController < Api::BaseController
after_action :insert_pagination_headers
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
@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)
end
private

View File

@ -6,8 +6,11 @@ class Api::V1::FavouritesController < Api::BaseController
after_action :insert_pagination_headers
def index
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
@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)
end
private

View File

@ -44,10 +44,13 @@ class Api::V1::StatusesController < Api::BaseController
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
@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
@ -64,7 +67,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
@ -128,6 +132,7 @@ class Api::V1::StatusesController < Api::BaseController
:visibility,
:language,
:scheduled_at,
:quote_id,
media_ids: [],
media_attributes: [
:id,

View File

@ -6,11 +6,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
@statuses = load_statuses
@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

@ -5,8 +5,12 @@ class Api::V1::Timelines::PublicController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
@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)
end
private

View File

@ -5,8 +5,12 @@ class Api::V1::Timelines::TagController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
@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)
end
private

View File

@ -23,6 +23,7 @@ module ContextHelper
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 full_context

View File

@ -15,7 +15,12 @@ module FormattingHelper
module_function :extract_status_plain_text
def status_content_format(status)
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
html_aware_format(
status.text,
status.local?,
preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []),
quote: status.respond_to?(:quote) && status.quote,
)
end
def rss_status_content_format(status)

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

@ -22,6 +22,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';
@ -120,6 +122,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,
@ -193,6 +212,7 @@ export function submitCompose(routerHistory) {
visibility: getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']),
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

@ -80,6 +80,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

@ -75,6 +75,8 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
normalStatus.quote = normalOldStatus.get('quote');
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
} else {
// If the status has a CW but no contents, treat the CW as if it were the
// status' contents, to avoid having a CW toggle with seemingly no effect.
@ -91,6 +93,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

@ -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 const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
@ -347,3 +350,25 @@ export const undoStatusTranslation = id => ({
type: STATUS_TRANSLATE_UNDO,
id,
});
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';
@ -39,6 +41,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,
@ -111,6 +114,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';
@ -22,6 +23,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({
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
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 = {
@ -87,7 +165,9 @@ class Status extends ImmutablePureComponent {
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
onQuoteToggleHidden: PropTypes.func,
muted: PropTypes.bool,
quoteMuted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
@ -103,6 +183,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
@ -114,10 +195,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,
forceFilter: undefined,
};
@ -126,6 +209,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 {
@ -137,6 +221,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
};
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
@ -165,6 +253,15 @@ class Status extends ImmutablePureComponent {
this._openProfile(proper);
};
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']))}`);
}
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
};
@ -177,6 +274,10 @@ class Status extends ImmutablePureComponent {
this.props.onTranslate(this._properStatus());
};
handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this._properStatus());
}
renderLoadingMediaGallery () {
return <div className='media-gallery' style={{ height: '110px' }} />;
}
@ -309,10 +410,9 @@ class Status extends ImmutablePureComponent {
};
render () {
let media = null;
let statusAvatar, prepend, rebloggedByText;
let prepend, rebloggedByText;
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture } = this.props;
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props;
let { status, account, ...other } = this.props;
@ -397,103 +497,123 @@ class Status extends ImmutablePureComponent {
);
}
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
media = (
<AttachmentList
const media = (status, quote = false) => {
if (pictureInPicture.get('inUse')) {
return <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) {
return (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer}>
{Component => (
<Component
src={attachment.get('url')}
alt={attachment.get('description')}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
quote={quote}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer}>
{Component => (
<Component
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={attachment.get('description')}
width={this.props.cachedMediaWidth}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
quote={quote}
/>
)}
</Bundle>
);
} else {
return (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
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') && !this.props.muted) {
return (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
media={status.get('media_attachments')}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
quote={quote}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={attachment.get('url')}
alt={attachment.get('description')}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={attachment.get('description')}
width={this.props.cachedMediaWidth}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
}
} else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/>
);
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
return null;
};
const statusAvatar = (status, account) => {
if (account === undefined || account === null) {
return <Avatar account={status.get('account')} size={46} />;
} else {
return <AvatarOverlay account={status.get('account')} friend={account} />;
}
};
const identity = (status, account) => (
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar(status, account)}
</div>
<DisplayName account={status.get('account')} />
</a>
);
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
@ -516,13 +636,7 @@ class Status extends ImmutablePureComponent {
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} 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')} />
</a>
{identity(status, account, false)}
</div>
<StatusContent
@ -535,7 +649,9 @@ class Status extends ImmutablePureComponent {
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} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>

View File

@ -26,6 +26,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' },
@ -68,6 +70,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,
@ -138,6 +141,10 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history);
}
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
};
@ -231,6 +238,14 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onFilter();
};
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, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -361,7 +376,8 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar'>
<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')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<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} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar__button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='quote-right' onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{shareButton}

View File

@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon';
import { autoPlayGif, languages as preloadedLanguages, translationEnabled } from 'mastodon/initial_state';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
const QUOTE_MAX_HEIGHT = 112; // 22px * 5 (+ 2px padding at the top)
class TranslateButton extends React.PureComponent {
@ -64,6 +65,7 @@ class StatusContent extends React.PureComponent {
collapsable: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
intl: PropTypes.object,
quote: PropTypes.bool,
};
state = {
@ -107,12 +109,12 @@ class StatusContent extends React.PureComponent {
}
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsable, onClick } = this.props;
const { collapsable, onClick, quote } = this.props;
const collapsed =
collapsable
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& node.clientHeight > (quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT)
&& status.get('spoiler_text').length === 0;
onCollapsedToggle(collapsed);
@ -216,7 +218,7 @@ class StatusContent extends React.PureComponent {
};
render () {
const { status, intl } = this.props;
const { status, intl, quote } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');
@ -245,6 +247,12 @@ class StatusContent extends React.PureComponent {
<PollContainer pollId={status.get('poll')} />
);
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';
@ -27,6 +28,8 @@ import {
editStatus,
translateStatus,
undoStatusTranslation,
hideQuote,
revealQuote,
} from '../actions/statuses';
import {
unmuteAccount,
@ -58,6 +61,8 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
editMessage: { id: 'confirmations.edit.message', defaultMessage: 'Editing 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' },
});
@ -107,6 +112,22 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
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));
@ -234,6 +255,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
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

@ -50,6 +50,7 @@ class Audio extends React.PureComponent {
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
quote: PropTypes.bool,
};
state = {
@ -94,7 +95,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

@ -235,6 +235,7 @@ class ComposeForm extends ImmutablePureComponent {
<WarningContainer />
<ReplyIndicatorContainer />
<ReplyIndicatorContainer quote />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
<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,22 +1,23 @@
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 => {
const mapStateToProps = (state, props) => {
let statusId = state.getIn(['compose', 'id'], null);
let editing = true;
if (statusId === null) {
statusId = state.getIn(['compose', 'in_reply_to']);
statusId = state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']);
editing = false;
}
return {
status: getStatus(state, { id: statusId }),
quote: props.quote,
editing,
};
};
@ -26,8 +27,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 { initBoostModal } from 'mastodon/actions/boosts';
@ -20,9 +21,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' },
});
@ -135,6 +140,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';
@ -20,6 +21,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' },
@ -61,6 +64,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,
@ -88,6 +92,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);
};
@ -286,6 +294,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>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
{shareButton}

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';
@ -25,7 +27,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 = {
@ -37,17 +39,21 @@ class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
onQuoteToggleHidden: PropTypes.func.isRequired,
onTranslate: 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 = {
@ -56,8 +62,9 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
const acct = e.currentTarget.getAttribute('data-acct');
e.preventDefault();
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
this.context.router.history.push(`/@${acct}`);
}
e.stopPropagation();
@ -71,6 +78,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 }));
@ -112,13 +132,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';
@ -129,63 +148,84 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const identity = (status, _0, _1, quote = false) => (
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} data-acct={status.getIn(['account', 'acct'])} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={quote ? 18 : 46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
);
media = (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const media = (status, quote = false) => {
if (pictureInPicture.get('inUse')) {
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 = (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={attachment.get('description')}
width={300}
height={150}
inline
onOpenVideo={this.handleOpenVideo}
return (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={this.props.onToggleMediaVisibility}
quote={quote}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
return (
<Video
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={attachment.get('description')}
width={300}
height={150}
inline
onOpenVideo={this.handleOpenVideo}
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
quote={quote}
/>
);
} else {
return (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
height={300}
onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
quote={quote}
/>
);
}
} else if (status.get('spoiler_text').length === 0) {
return (
<Card
sensitive={status.get('sensitive')}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
/>
);
} else {
media = (
<MediaGallery
standalone
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
height={300}
onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia}
onToggleVisibility={this.props.onToggleMediaVisibility}
card={status.get('card', null)}
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 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>;
@ -261,10 +301,7 @@ 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', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
{identity(status, null, null, false)}
<StatusContent
status={status}
@ -273,7 +310,9 @@ class DetailedStatus extends ImmutablePureComponent {
onTranslate={this.handleTranslate}
/>
{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.getIn(['account', 'acct'])}\/${status.get('id')}`} 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';
@ -165,6 +167,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

@ -23,6 +23,7 @@ import {
} from '../../actions/interactions';
import {
replyCompose,
quoteCompose,
mentionCompose,
directCompose,
} from '../../actions/compose';
@ -35,6 +36,8 @@ import {
revealStatus,
translateStatus,
undoStatusTranslation,
hideQuote,
revealQuote,
} from '../../actions/statuses';
import {
unblockAccount,
@ -73,6 +76,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' },
});
@ -205,6 +210,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,
};
@ -227,7 +233,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'),
});
}
}
@ -235,6 +245,10 @@ class Status extends ImmutablePureComponent {
this.setState({ showMedia: !this.state.showMedia });
};
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
handleFavouriteClick = (status) => {
const { dispatch } = this.props;
const { signedIn } = this.context.identity;
@ -285,6 +299,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, privacy) => {
this.props.dispatch(reblog(status, privacy));
};
@ -388,6 +415,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());
@ -640,9 +675,12 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
onToggleHidden={this.handleToggleHidden}
onTranslate={this.handleTranslate}
onQuoteToggleHidden={this.handleQuoteToggleHidden}
domain={domain}
showMedia={this.state.showMedia}
showQuoteMedia={this.state.showQuoteMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
pictureInPicture={pictureInPicture}
/>
@ -652,6 +690,7 @@ class Status extends ImmutablePureComponent {
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
onQuote={this.handleQuoteClick}
onBookmark={this.handleBookmarkClick}
onDelete={this.handleDeleteClick}
onEdit={this.handleEditClick}

View File

@ -121,6 +121,7 @@ class Video extends React.PureComponent {
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
quote: PropTypes.bool,
componentIndex: PropTypes.number,
autoFocus: PropTypes.bool,
};
@ -524,7 +525,7 @@ class Video extends React.PureComponent {
}
render () {
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, autoFocus, 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 = {};
@ -538,6 +539,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

@ -607,6 +607,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"
@ -764,6 +772,14 @@
{
"defaultMessage": "Replied to {name}",
"id": "status.replied_to"
},
{
"defaultMessage": "Muted quote",
"id": "status.muted_quote"
},
{
"defaultMessage": "Unlisted quote",
"id": "status.unlisted_quote"
}
],
"path": "app/javascript/mastodon/components/status.json"
@ -3716,6 +3732,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

@ -169,6 +169,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 post and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.reply.confirm": "Reply",
@ -553,6 +555,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 post",
"status.delete": "Delete",
@ -574,9 +577,11 @@
"status.more": "More",
"status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation",
"status.muted_quote": "Muted quote",
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.quote": "Quote",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",
@ -599,6 +604,7 @@
"status.translate": "Translate",
"status.translated_from_with": "Translated from {lang} using {provider}",
"status.uncached_media_warning": "Not available",
"status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.",

View File

@ -170,6 +170,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": "返信",
@ -556,6 +558,7 @@
"status.cancel_reblog_private": "ブースト解除",
"status.cannot_reblog": "このトゥートはブーストできません",
"status.copy": "トゥートへのリンクをコピー",
"status.cannot_quote": "このトゥートは引用できません",
"status.delete": "削除",
"status.detailed_status": "詳細な会話ビュー",
"status.direct": "@{name}さんにダイレクトメッセージ",
@ -575,9 +578,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": "ブースト",
@ -600,6 +605,7 @@
"status.translate": "翻訳",
"status.translated_from_with": "{provider}を使って{lang}から翻訳",
"status.uncached_media_warning": "利用できません",
"status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除",
"status.unpin": "プロフィールへの固定を解除",
"subscribed_languages.lead": "選択した言語のトゥートだけがホームとリストのタイムラインに表示されます。全ての言語のトゥートを受け取る場合は全てのチェックを外して下さい。",

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,
@ -67,6 +69,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,
@ -119,6 +123,8 @@ 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('quote_from_url', null);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', state.get('default_sensitive'));
map.set('language', state.get('default_language'));
@ -250,6 +256,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) {
@ -323,10 +340,20 @@ 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 => {
map.set('id', null);
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
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);
@ -358,6 +385,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
@ -456,8 +484,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((media) => media.set('unattached', true)));
map.set('focusDate', new Date());

View File

@ -10,6 +10,7 @@ import {
import {
COMPOSE_MENTION,
COMPOSE_REPLY,
COMPOSE_QUOTE,
COMPOSE_DIRECT,
} from '../actions/compose';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
@ -37,6 +38,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

@ -17,6 +17,8 @@ import {
STATUS_TRANSLATE_UNDO,
STATUS_FETCH_REQUEST,
STATUS_FETCH_FAIL,
QUOTE_REVEAL,
QUOTE_HIDE,
} from '../actions/statuses';
import { TIMELINE_DELETE } from '../actions/timelines';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@ -83,6 +85,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);
case STATUS_TRANSLATE_SUCCESS:

View File

@ -2,6 +2,7 @@ import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { toServerSideType } from 'mastodon/utils/filters';
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);
@ -35,22 +36,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'])]),
getFilters,
],
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
(statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationshipBase, relationshipReblog, relationshipQuote, movedBase, movedReblog, movedQuote, filters) => {
if (!statusBase || statusBase.get('isLoading')) {
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);
}
let filtered = false;
if ((accountReblog || accountBase).get('id') !== me && filters) {
let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
@ -65,6 +98,7 @@ export const makeGetStatus = () => {
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('quote', statusQuote);
map.set('account', accountBase);
map.set('matched_filters', filtered);
});

View File

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

View File

@ -322,6 +322,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

@ -827,6 +827,10 @@ body > [data-popper-placement] {
min-height: 23px;
overflow-y: auto;
flex: 0 2 auto;
&.quote-indicator {
background: $success-green;
}
}
.reply-indicator__header {
@ -1008,6 +1012,10 @@ body > [data-popper-placement] {
.status__content.status__content--collapsed {
max-height: 22px * 15; // 15 lines is roughly above 500 characters
.quote-status & {
max-height: 22px * 5;
}
}
.status__content__read-more-button {
@ -1080,6 +1088,69 @@ body > [data-popper-placement] {
}
}
.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 {
position: absolute;
top: 5px !important;
left: 5px !important;
}
.display-name {
padding-left: 56px;
}
.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;
}
.focusable {
&:focus {
outline: 0;
@ -1095,9 +1166,12 @@ body > [data-popper-placement] {
.status {
padding: 16px;
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%);
}
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; }

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

@ -76,6 +76,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@silenced_account_ids = []
@params = {}
process_quote
process_status_params
process_tags
process_audience
@ -126,6 +127,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
@ -426,4 +428,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
poll.reload
retry
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

@ -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

@ -13,7 +13,8 @@ class TextFormatter
multiline: true,
}.freeze
attr_reader :text, :options
attr_accessor :text
attr_reader :options
# @param [String] text
# @param [Hash] options
@ -21,6 +22,7 @@ class TextFormatter
# @option options [Boolean] :with_domains
# @option options [Boolean] :with_rel_me
# @option options [Array<Account>] :preloaded_accounts
# @option options [Status] :quote
def initialize(text, options = {})
@text = text
@options = DEFAULT_OPTIONS.merge(options)
@ -31,7 +33,7 @@ class TextFormatter
end
def to_s
return ''.html_safe if text.blank?
return ''.html_safe if text.blank? & !quote?
html = rewrite do |entity|
if entity[:url]
@ -43,6 +45,8 @@ class TextFormatter
end
end
html += render_quote if quote?
html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?
html.html_safe # rubocop:disable Rails/OutputSafety
@ -118,7 +122,7 @@ class TextFormatter
return "@#{h(entity[:screen_name])}" if account.nil?
url = ActivityPub::TagManager.instance.url_for(account)
url = ap_tag_manager.url_for(account)
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
<<~HTML.squish
@ -126,6 +130,13 @@ class TextFormatter
HTML
end
def render_quote
link = link_to_url({ url: ap_tag_manager.url_for(quote) })
<<~HTML.squish
<span class="quote-inline"><br/>~~~~~~~~~~<br/>[#{link}]</span>
HTML
end
def entity_cache
@entity_cache ||= EntityCache.instance
end
@ -134,6 +145,10 @@ class TextFormatter
@tag_manager ||= TagManager.instance
end
def ap_tag_manager
@ap_tag_manager ||= ActivityPub::TagManager.instance
end
delegate :local_domain?, to: :tag_manager
def multiline?
@ -155,4 +170,12 @@ class TextFormatter
def preloaded_accounts?
preloaded_accounts.present?
end
def quote
options[:quote]
end
def quote?
quote.present?
end
end

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
# edited_at :datetime
@ -59,6 +60,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
@ -69,6 +71,7 @@ class Status < ApplicationRecord
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
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
@ -84,6 +87,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
@ -191,6 +195,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
@ -255,7 +267,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 ordered_media_attachments

View File

@ -11,6 +11,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?
attribute :updated, if: :edited?
@ -138,6 +141,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

@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration,
:registrations
:registrations, :feature_quote
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer
@ -88,6 +88,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
}
end
def feature_quote
true
end
private
def registrations_enabled?

View File

@ -18,6 +18,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
@ -42,6 +44,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
@ -182,3 +188,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

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

View File

@ -21,17 +21,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!
@ -51,6 +54,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?
@ -58,10 +74,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
@ -171,6 +198,7 @@ class PostStatusService < BaseService
language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale),
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 prefers_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: prefers_autoplay?)
%span.display-name__account
= acct(author)
- unless inline
= fa_icon('lock') if author.locked?

View File

@ -1,40 +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 prefers_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: prefers_autoplay?)
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
= render 'statuses/author', author: status.account, 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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content{ lang: status.language }
= prerender_custom_emojis(status_content_format(status), status.emojis)
= render 'statuses/text', status: status
- if status.preloadable_poll
= render_poll_component(status)
- if status.quote?
= render 'statuses/quote', status: status.quote
- if !status.ordered_media_attachments.empty?
- if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 670, height: 380, detailed: true)
- elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status, width: 670, height: 380)
- else
= render_media_gallery_component(status, height: 380, standalone: true)
- elsif status.preview_card
= render_card_component(status)
= render 'statuses/media', status: status, detailed: true
.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }

View File

@ -0,0 +1,13 @@
:ruby
detailed ||= false
quote ||= false
- if !status.ordered_media_attachments.empty?
- if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 670, height: 380, detailed: detailed)
- elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status, width: 670, height: 380)
- else
= render_media_gallery_component(status, height: 380, standalone: true)
- elsif status.preview_card
= render_card_component(status, quote: quote)

View File

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

View File

@ -12,41 +12,14 @@
*
%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 prefers_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: prefers_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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content{ lang: status.language }
= prerender_custom_emojis(status_content_format(status), status.emojis)
= render 'statuses/author', author: status.account
- if status.preloadable_poll
= render_poll_component(status)
= render 'statuses/text', status: status
- if !status.ordered_media_attachments.empty?
- if status.ordered_media_attachments.first.video?
= render_video_component(status, width: 610, height: 343)
- elsif status.ordered_media_attachments.first.audio?
= render_audio_component(status, width: 610, height: 343)
- else
= render_media_gallery_component(status, height: 343)
- elsif status.preview_card
= render_card_component(status)
- if status.quote?
= render 'statuses/quote', status: status.quote
= render 'statuses/media', status: status
- 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,9 @@
.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> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
%button.status__content__spoiler-link= t('statuses.show_more')
.e-content{ :lang => status.language }
= prerender_custom_emojis(status_content_format(status), status.emojis)
- if status.preloadable_poll
= render_poll_component(status)

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

@ -935,6 +935,7 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) 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.datetime "edited_at"
@ -947,6 +948,7 @@ ActiveRecord::Schema.define(version: 2022_12_06_114142) 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", where: "(in_reply_to_account_id IS NOT NULL)"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id", where: "(in_reply_to_id IS NOT NULL)"
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, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)"
end

View File

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