Merge remote-tracking branch 'accelforce/custom/quote' into features/v4.1.2
This commit is contained in:
commit
1b02b4bfde
@ -8,7 +8,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
|
||||
|
||||
def index
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -7,7 +7,10 @@ class Api::V1::BookmarksController < Api::BaseController
|
||||
|
||||
def index
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -7,7 +7,10 @@ class Api::V1::FavouritesController < Api::BaseController
|
||||
|
||||
def index
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -46,8 +46,11 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
render json: @context, serializer: REST::ContextSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
def create
|
||||
@ -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,
|
||||
|
@ -7,10 +7,12 @@ class Api::V1::Timelines::HomeController < Api::BaseController
|
||||
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @statuses,
|
||||
each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id),
|
||||
status: account_home_feed.regenerating? ? 206 : 200
|
||||
end
|
||||
|
||||
|
@ -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
|
||||
|
@ -6,7 +6,11 @@ class Api::V1::Timelines::PublicController < Api::BaseController
|
||||
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -6,7 +6,11 @@ class Api::V1::Timelines::TagController < Api::BaseController
|
||||
|
||||
def show
|
||||
@statuses = load_statuses
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
||||
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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));
|
||||
|
@ -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']),
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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') {
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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,11 +497,12 @@ class Status extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
const media = (status, quote = false) => {
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||
return <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (this.props.muted) {
|
||||
media = (
|
||||
return (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={status.get('media_attachments')}
|
||||
@ -410,7 +511,7 @@ class Status extends ImmutablePureComponent {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -429,6 +530,7 @@ class Status extends ImmutablePureComponent {
|
||||
blurhash={attachment.get('blurhash')}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
@ -436,7 +538,7 @@ class Status extends ImmutablePureComponent {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -452,14 +554,15 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
|
||||
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -469,15 +572,16 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
|
||||
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0 && status.get('card') && !this.props.muted) {
|
||||
media = (
|
||||
return (
|
||||
<Card
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
card={status.get('card')}
|
||||
@ -485,15 +589,31 @@ class Status extends ImmutablePureComponent {
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
sensitive={status.get('sensitive')}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusAvatar = (status, account) => {
|
||||
if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={46} />;
|
||||
return <Avatar account={status.get('account')} size={46} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
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>
|
||||
|
@ -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}
|
||||
|
@ -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 = '';
|
||||
|
||||
|
@ -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> }} />,
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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());
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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'>
|
||||
|
@ -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,13 +148,21 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
outerStyle.height = `${this.state.height}px`;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
const media = (status, quote = false) => {
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder />;
|
||||
return <PictureInPicturePlaceholder />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
@ -149,12 +176,13 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
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]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||
@ -168,10 +196,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
return (
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
@ -180,13 +209,24 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
|
||||
return (
|
||||
<Card
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
card={status.get('card', null)}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
|
||||
}
|
||||
@ -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'>
|
||||
|
@ -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));
|
||||
|
@ -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}
|
||||
|
@ -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) {
|
||||
|
@ -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"
|
||||
|
@ -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.",
|
||||
|
@ -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": "選択した言語のトゥートだけがホームとリストのタイムラインに表示されます。全ての言語のトゥートを受け取る場合は全てのチェックを外して下さい。",
|
||||
|
@ -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);
|
||||
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());
|
||||
|
@ -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);
|
||||
|
@ -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:
|
||||
|
@ -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);
|
||||
});
|
||||
|
1
app/javascript/mastodon/utils/uniq.js
Normal file
1
app/javascript/mastodon/utils/uniq.js
Normal file
@ -0,0 +1 @@
|
||||
export const uniq = (array) => array.filter((x, i, self) => self.indexOf(x) === i);
|
@ -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()
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
20
app/views/statuses/_author.html.haml
Normal file
20
app/views/statuses/_author.html.haml
Normal 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?
|
@ -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)}
|
||||
%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 }
|
||||
|
13
app/views/statuses/_media.html.haml
Normal file
13
app/views/statuses/_media.html.haml
Normal 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)
|
6
app/views/statuses/_quote.html.haml
Normal file
6
app/views/statuses/_quote.html.haml
Normal 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
|
@ -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)}
|
||||
%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
|
||||
|
9
app/views/statuses/_text.html.haml
Normal file
9
app/views/statuses/_text.html.haml
Normal 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)}
|
||||
%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)
|
5
db/migrate/20180419235016_add_quote_id_to_statuses.rb
Normal file
5
db/migrate/20180419235016_add_quote_id_to_statuses.rb
Normal 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
|
@ -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
|
@ -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
|
||||
|
@ -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(' ')
|
||||
|
Loading…
Reference in New Issue
Block a user