Implement quote feature
This commit is contained in:
parent
633d175146
commit
2aa6ec14fe
@ -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
|
||||
|
@ -30,8 +30,11 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
|
||||
render json: @context, serializer: REST::ContextSerializer,
|
||||
relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id),
|
||||
account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
|
||||
end
|
||||
|
||||
def create
|
||||
@ -46,7 +49,8 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
application: doorkeeper_token.application,
|
||||
poll: status_params[:poll],
|
||||
idempotency: request.headers['Idempotency-Key'],
|
||||
with_rate_limit: true)
|
||||
with_rate_limit: true,
|
||||
quote_id: status_params[:quote_id].presence)
|
||||
|
||||
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||
end
|
||||
@ -85,6 +89,7 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
:spoiler_text,
|
||||
:visibility,
|
||||
:scheduled_at,
|
||||
:quote_id,
|
||||
media_ids: [],
|
||||
poll: [
|
||||
:multiple,
|
||||
|
@ -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
|
||||
|
@ -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));
|
||||
|
@ -19,6 +19,8 @@ export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
|
||||
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
|
||||
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
|
||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||
export const COMPOSE_RESET = 'COMPOSE_RESET';
|
||||
@ -100,6 +102,23 @@ export function cancelReplyCompose() {
|
||||
};
|
||||
};
|
||||
|
||||
export function quoteCompose(status, routerHistory) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_QUOTE,
|
||||
status: status,
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelQuoteCompose() {
|
||||
return {
|
||||
type: COMPOSE_QUOTE_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function resetCompose() {
|
||||
return {
|
||||
type: COMPOSE_RESET,
|
||||
@ -147,6 +166,7 @@ export function submitCompose(routerHistory) {
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
quote_id: getState().getIn(['compose', 'quote_from'], null),
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
@ -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));
|
||||
|
@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) {
|
||||
processStatus(status.reblog);
|
||||
}
|
||||
|
||||
if (status.quote && status.quote.id) {
|
||||
processStatus(status.quote);
|
||||
}
|
||||
|
||||
if (status.poll && status.poll.id) {
|
||||
pushUnique(polls, normalizePoll(status.poll));
|
||||
}
|
||||
|
@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
|
||||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||
normalStatus.hidden = normalOldStatus.get('hidden');
|
||||
normalStatus.quote = normalOldStatus.get('quote');
|
||||
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
|
||||
} else {
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
@ -69,6 +71,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
if (status.quote) {
|
||||
normalStatus.quote = normalizeStatus(status.quote, null);
|
||||
normalStatus.quote_hidden = normalStatus.quote.hidden;
|
||||
}
|
||||
}
|
||||
|
||||
return normalStatus;
|
||||
|
@ -46,7 +46,7 @@ defineMessages({
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
|
||||
const accountIds = notifications.map(item => item.account.id);
|
||||
|
||||
if (accountIds.length > 0) {
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
|
@ -28,6 +28,9 @@ export const STATUS_REVEAL = 'STATUS_REVEAL';
|
||||
export const STATUS_HIDE = 'STATUS_HIDE';
|
||||
export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
|
||||
|
||||
export const QUOTE_REVEAL = 'QUOTE_REVEAL';
|
||||
export const QUOTE_HIDE = 'QUOTE_HIDE';
|
||||
|
||||
export const REDRAFT = 'REDRAFT';
|
||||
|
||||
export function fetchStatusRequest(id, skipLoading) {
|
||||
@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
|
||||
isCollapsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function hideQuote(ids) {
|
||||
if (!Array.isArray(ids)) {
|
||||
ids = [ids];
|
||||
}
|
||||
|
||||
return {
|
||||
type: QUOTE_HIDE,
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
||||
export function revealQuote(ids) {
|
||||
if (!Array.isArray(ids)) {
|
||||
ids = [ids];
|
||||
}
|
||||
|
||||
return {
|
||||
type: QUOTE_REVEAL,
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { submitMarkers } from './markers';
|
||||
import api, { getLinks } from 'mastodon/api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
import { uniq } from '../utils/uniq';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
@ -30,6 +32,7 @@ export function updateTimeline(timeline, status, accept) {
|
||||
}
|
||||
|
||||
dispatch(importFetchedStatus(status));
|
||||
dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(x => x)));
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
@ -102,6 +105,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
dispatch(fetchRelationships(uniq(response.data.map(item => [item.reblog ? item.reblog.account.id : item.account.id, item.quote ? item.quote.account.id : null]).flat().filter(x => x))));
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||
|
||||
if (timelineId === 'home') {
|
||||
|
@ -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';
|
||||
@ -23,6 +24,29 @@ import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_
|
||||
// to use the progress bar to show download progress
|
||||
import Bundle from '../features/ui/components/bundle';
|
||||
|
||||
export const mapStateToProps = (state, props) => {
|
||||
let status = props.status;
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
if (status.get('quote', null) === null) {
|
||||
return {
|
||||
quoteMuted: !!status.get('quote_id', null),
|
||||
};
|
||||
}
|
||||
|
||||
const id = status.getIn(['quote', 'account', 'id'], null);
|
||||
return {
|
||||
quoteMuted: id !== null && (state.getIn(['relationships', id, 'muting']) || state.getIn(['relationships', id, 'blocking']) || state.getIn(['relationships', id, 'blocked_by']) || state.getIn(['relationships', id, 'domain_blocking']) || status.getIn(['quote', 'muted'])),
|
||||
};
|
||||
};
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
|
||||
@ -59,7 +83,61 @@ const messages = defineMessages({
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpandedQuoteToggle, identity, media, router, contextType = 'home') => {
|
||||
const quoteStatus = status.get('quote', null);
|
||||
|
||||
if (!quoteStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quoteInner = (() => {
|
||||
const hideUnlisted = quoteStatus.get('visibility') === 'unlisted'
|
||||
&& ['public', 'community', 'hashtag'].includes(contextType);
|
||||
if (quoteMuted || hideUnlisted) {
|
||||
const content = (() => {
|
||||
if (quoteMuted) {
|
||||
return (
|
||||
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleQuoteClick}>
|
||||
<FormattedMessage id='status.unlisted_quote' defaultMessage='Unlisted quote' />
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={classNames('status__content', { 'muted-quote': quoteMuted, 'unlisted-quote': hideUnlisted, 'status__content--with-action': router })}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='status__info'>
|
||||
{identity(quoteStatus, null, null, true)}
|
||||
</div>
|
||||
<StatusContent status={quoteStatus} onClick={handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={handleExpandedQuoteToggle} quote />
|
||||
{media(quoteStatus, true)}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('quote-status', `status-${quoteStatus.get('visibility')}`, { muted: muted })}
|
||||
data-id={quoteStatus.get('id')}
|
||||
dataurl={quoteStatus.get('url')}
|
||||
>
|
||||
{quoteInner}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps) @injectIntl
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
@ -85,7 +163,9 @@ class Status extends ImmutablePureComponent {
|
||||
onHeightChange: PropTypes.func,
|
||||
onToggleHidden: PropTypes.func,
|
||||
onToggleCollapsed: PropTypes.func,
|
||||
onQuoteToggleHidden: PropTypes.func,
|
||||
muted: PropTypes.bool,
|
||||
quoteMuted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
@ -101,6 +181,7 @@ class Status extends ImmutablePureComponent {
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
contextType: PropTypes.string,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
@ -112,10 +193,12 @@ class Status extends ImmutablePureComponent {
|
||||
'hidden',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'quoteMuted',
|
||||
];
|
||||
|
||||
state = {
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||
statusId: undefined,
|
||||
};
|
||||
|
||||
@ -123,6 +206,7 @@ class Status extends ImmutablePureComponent {
|
||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
return {
|
||||
showMedia: defaultMediaVisibility(nextProps.status),
|
||||
showQuoteMedia: defaultMediaVisibility(nextProps.status ? nextProps.status.get('quote', null) : null),
|
||||
statusId: nextProps.status.get('id'),
|
||||
};
|
||||
} else {
|
||||
@ -134,6 +218,10 @@ class Status extends ImmutablePureComponent {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleToggleQuoteMediaVisibility = () => {
|
||||
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick();
|
||||
@ -164,6 +252,15 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = this.props;
|
||||
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
@ -180,6 +277,10 @@ class Status extends ImmutablePureComponent {
|
||||
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
|
||||
}
|
||||
|
||||
handleExpandedQuoteToggle = () => {
|
||||
this.props.onQuoteToggleHidden(this._properStatus());
|
||||
}
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||
}
|
||||
@ -280,10 +381,9 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
let media = null;
|
||||
let statusAvatar, prepend, rebloggedByText;
|
||||
let prepend, rebloggedByText;
|
||||
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, quoteMuted, contextType } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
@ -354,11 +454,12 @@ class Status extends ImmutablePureComponent {
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
const media = (status, quote = false) => {
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||
return <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (this.props.muted) {
|
||||
media = (
|
||||
return (
|
||||
<AttachmentList
|
||||
compact
|
||||
media={status.get('media_attachments')}
|
||||
@ -367,7 +468,7 @@ class Status extends ImmutablePureComponent {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -382,6 +483,7 @@ class Status extends ImmutablePureComponent {
|
||||
height={110}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
@ -389,7 +491,7 @@ class Status extends ImmutablePureComponent {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -405,14 +507,15 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
|
||||
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
@ -422,15 +525,16 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
visible={quote ? this.state.showQuoteMedia : this.state.showMedia}
|
||||
onToggleVisibility={quote ? this.handleToggleQuoteMediaVisibility : this.handleToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
|
||||
media = (
|
||||
return (
|
||||
<Card
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
card={status.get('card')}
|
||||
@ -438,17 +542,33 @@ class Status extends ImmutablePureComponent {
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
sensitive={status.get('sensitive')}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const statusAvatar = (status, account, otherAccounts, quote = false) => {
|
||||
if (otherAccounts && otherAccounts.size > 0) {
|
||||
statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} />;
|
||||
return <AvatarComposite accounts={otherAccounts} size={quote ? 18 : 48} />;
|
||||
} else if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={48} />;
|
||||
return <Avatar account={status.get('account')} size={quote ? 18 : 48} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
return <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
};
|
||||
|
||||
const identity = (status, account, otherAccounts, quote = false) => (
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar(status, account, otherAccounts, quote)}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} others={otherAccounts} />
|
||||
</a>
|
||||
);
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
@ -472,18 +592,14 @@ class Status extends ImmutablePureComponent {
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</a>
|
||||
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} others={otherAccounts} />
|
||||
</a>
|
||||
{identity(status, account, otherAccounts, false)}
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
|
||||
|
||||
{media}
|
||||
{media(status)}
|
||||
|
||||
{quote(status, this.props.muted, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router, contextType)}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
</div>
|
||||
|
@ -24,6 +24,8 @@ const messages = defineMessages({
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
@ -61,6 +63,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onQuote: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
@ -125,6 +128,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
this.props.onQuote(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleBookmarkClick = () => {
|
||||
this.props.onBookmark(this.props.status);
|
||||
}
|
||||
@ -220,6 +227,14 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
static quoteTitle = (intl, messages, publicStatus) => {
|
||||
if (publicStatus) {
|
||||
return intl.formatMessage(messages.quote);
|
||||
} else {
|
||||
return intl.formatMessage(messages.cannot_quote);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
|
||||
|
||||
@ -322,6 +337,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='quote-right' onClick={this.handleQuoteClick} />
|
||||
|
||||
{shareButton}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import Icon from 'mastodon/components/icon';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
|
||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||
const QUOTE_MAX_HEIGHT = 102; // 20px * 5 (+ 2px padding at the top)
|
||||
|
||||
export default class StatusContent extends React.PureComponent {
|
||||
|
||||
@ -24,6 +25,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
onClick: PropTypes.func,
|
||||
collapsable: PropTypes.bool,
|
||||
onCollapsedToggle: PropTypes.func,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -66,7 +68,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
let collapsed =
|
||||
this.props.collapsable
|
||||
&& this.props.onClick
|
||||
&& node.clientHeight > MAX_HEIGHT
|
||||
&& node.clientHeight > (this.props.quote ? QUOTE_MAX_HEIGHT : MAX_HEIGHT)
|
||||
&& this.props.status.get('spoiler_text').length === 0;
|
||||
|
||||
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
|
||||
@ -173,7 +175,7 @@ export default class StatusContent extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
const { status, quote } = this.props;
|
||||
|
||||
if (status.get('content').length === 0) {
|
||||
return null;
|
||||
@ -203,6 +205,12 @@ export default class StatusContent extends React.PureComponent {
|
||||
</button>
|
||||
);
|
||||
|
||||
if (quote) {
|
||||
const doc = new DOMParser().parseFromString(content.__html, 'text/html').documentElement;
|
||||
Array.from(doc.querySelectorAll('br')).forEach(nl => nl.replaceWith(' '));
|
||||
content.__html = doc.outerHTML;
|
||||
}
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
|
@ -4,6 +4,7 @@ import Status from '../components/status';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
quoteCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../actions/compose';
|
||||
@ -24,6 +25,8 @@ import {
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
toggleStatusCollapse,
|
||||
hideQuote,
|
||||
revealQuote,
|
||||
} from '../actions/statuses';
|
||||
import {
|
||||
unmuteAccount,
|
||||
@ -49,6 +52,8 @@ const messages = defineMessages({
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
|
||||
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
||||
@ -98,6 +103,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onQuote (status, router) {
|
||||
dispatch((_, getState) => {
|
||||
let state = getState();
|
||||
|
||||
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.quoteMessage),
|
||||
confirm: intl.formatMessage(messages.quoteConfirm),
|
||||
onConfirm: () => dispatch(quoteCompose(status, router)),
|
||||
}));
|
||||
} else {
|
||||
dispatch(quoteCompose(status, router));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
@ -198,6 +219,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
|
||||
},
|
||||
|
||||
onQuoteToggleHidden (status) {
|
||||
if (status.get('quote_hidden')) {
|
||||
dispatch(revealQuote(status.get('id')));
|
||||
} else {
|
||||
dispatch(hideQuote(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlockDomain (domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
|
@ -47,7 +47,9 @@ export default class TimelineContainer extends React.PureComponent {
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
<Fragment>
|
||||
<div className='standalone-timeline'>
|
||||
{timeline}
|
||||
</div>
|
||||
|
||||
{ReactDOM.createPortal(
|
||||
<ModalContainer />,
|
||||
|
@ -42,6 +42,7 @@ class Audio extends React.PureComponent {
|
||||
volume: PropTypes.number,
|
||||
muted: PropTypes.bool,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -83,7 +84,11 @@ class Audio extends React.PureComponent {
|
||||
|
||||
_setDimensions () {
|
||||
const width = this.player.offsetWidth;
|
||||
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
||||
let height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
||||
|
||||
if (this.props.quote) {
|
||||
height /= 2;
|
||||
}
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
|
@ -201,6 +201,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
<WarningContainer />
|
||||
|
||||
<ReplyIndicatorContainer />
|
||||
<ReplyIndicatorContainer quote />
|
||||
|
||||
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
|
||||
<AutosuggestInput
|
||||
|
@ -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,13 +1,14 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { cancelReplyCompose } from '../../../actions/compose';
|
||||
import { cancelReplyCompose, cancelQuoteCompose } from '../../../actions/compose';
|
||||
import { makeGetStatus } from '../../../selectors';
|
||||
import ReplyIndicator from '../components/reply_indicator';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, { id: state.getIn(['compose', props.quote ? 'quote_from' : 'in_reply_to']) }),
|
||||
quote: props.quote,
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
@ -15,8 +16,8 @@ const makeMapStateToProps = () => {
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onCancel () {
|
||||
dispatch(cancelReplyCompose());
|
||||
onCancel (quote) {
|
||||
dispatch(quote ? cancelQuoteCompose() : cancelReplyCompose());
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -4,10 +4,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import StatusActionBar from 'mastodon/components/status_action_bar';
|
||||
import classNames from 'classnames';
|
||||
import { me, boostModal } from 'mastodon/initial_state';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
|
||||
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
@ -19,9 +20,13 @@ const messages = defineMessages({
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
|
||||
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
});
|
||||
|
||||
@ -106,6 +111,31 @@ class Footer extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
_performQuote = () => {
|
||||
const { dispatch, status, onClose } = this.props;
|
||||
const { router } = this.context;
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
dispatch(quoteCompose(status, router.history));
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
const { dispatch, askReplyConfirmation, intl } = this.props;
|
||||
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.quoteMessage),
|
||||
confirm: intl.formatMessage(messages.quoteConfirm),
|
||||
onConfirm: this._performQuote,
|
||||
}));
|
||||
} else {
|
||||
this._performQuote();
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenClick = e => {
|
||||
const { router } = this.context;
|
||||
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import StatusActionBar from '../../../components/status_action_bar';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
@ -18,6 +19,8 @@ const messages = defineMessages({
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
@ -56,6 +59,7 @@ class ActionBar extends React.PureComponent {
|
||||
relationship: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onQuote: PropTypes.func,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
@ -82,6 +86,10 @@ class ActionBar extends React.PureComponent {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
this.props.onQuote(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
this.props.onFavourite(this.props.status);
|
||||
}
|
||||
@ -276,6 +284,7 @@ class ActionBar extends React.PureComponent {
|
||||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
<div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
|
||||
{shareButton}
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||
|
||||
|
@ -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';
|
||||
@ -24,7 +26,7 @@ const messages = defineMessages({
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
export default @connect(mapStateToProps) @injectIntl
|
||||
class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
@ -36,16 +38,20 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
onToggleHidden: PropTypes.func.isRequired,
|
||||
onQuoteToggleHidden: PropTypes.func.isRequired,
|
||||
measureHeight: PropTypes.bool,
|
||||
onHeightChange: PropTypes.func,
|
||||
domain: PropTypes.string.isRequired,
|
||||
compact: PropTypes.bool,
|
||||
quoteMuted: PropTypes.bool,
|
||||
showMedia: PropTypes.bool,
|
||||
showQuoteMedia: PropTypes.bool,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
onQuoteToggleMediaVisibility: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -54,8 +60,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
@ -69,6 +76,19 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
this.props.onToggleHidden(this.props.status);
|
||||
}
|
||||
|
||||
handleExpandedQuoteToggle = () => {
|
||||
this.props.onQuoteToggleHidden(this.props.status);
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = this.props;
|
||||
this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
|
||||
}
|
||||
|
||||
_measureHeight (heightJustChanged) {
|
||||
if (this.props.measureHeight && this.node) {
|
||||
scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
|
||||
@ -105,13 +125,12 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
render () {
|
||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
const { intl, compact, pictureInPicture } = this.props;
|
||||
const { intl, compact, pictureInPicture, quoteMuted } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let media = '';
|
||||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let reblogIcon = 'retweet';
|
||||
@ -121,13 +140,21 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
outerStyle.height = `${this.state.height}px`;
|
||||
}
|
||||
|
||||
const identity = (status, _0, _1, quote = false) => (
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={quote ? 18 : 48} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
);
|
||||
|
||||
const media = (status, quote = false) => {
|
||||
if (pictureInPicture.get('inUse')) {
|
||||
media = <PictureInPicturePlaceholder />;
|
||||
return <PictureInPicturePlaceholder />;
|
||||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
@ -137,12 +164,13 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
height={150}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
return (
|
||||
<Video
|
||||
preview={attachment.get('preview_url')}
|
||||
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||
@ -156,10 +184,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
return (
|
||||
<MediaGallery
|
||||
standalone
|
||||
sensitive={status.get('sensitive')}
|
||||
@ -168,13 +197,24 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
visible={this.props.showMedia}
|
||||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0) {
|
||||
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
|
||||
return (
|
||||
<Card
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenMedia={this.props.onOpenMedia}
|
||||
card={status.get('card', null)}
|
||||
quote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
|
||||
}
|
||||
@ -240,14 +280,13 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
</a>
|
||||
{identity(status, null, null, false)}
|
||||
|
||||
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
|
||||
|
||||
{media}
|
||||
{media(status, false)}
|
||||
|
||||
{quote(status, false, quoteMuted, this.handleQuoteClick, this.handleExpandedQuoteToggle, identity, media, this.context.router)}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
||||
|
@ -20,6 +20,8 @@ import {
|
||||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
hideQuote,
|
||||
revealQuote,
|
||||
} from '../../../actions/statuses';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initBlockModal } from '../../../actions/blocks';
|
||||
@ -164,6 +166,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onQuoteToggleHidden (status) {
|
||||
if (status.get('quote_hidden')) {
|
||||
dispatch(revealQuote(status.get('id')));
|
||||
} else {
|
||||
dispatch(hideQuote(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
} from '../../actions/interactions';
|
||||
import {
|
||||
replyCompose,
|
||||
quoteCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../../actions/compose';
|
||||
@ -31,6 +32,8 @@ import {
|
||||
deleteStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
hideQuote,
|
||||
revealQuote,
|
||||
} from '../../actions/statuses';
|
||||
import {
|
||||
unblockAccount,
|
||||
@ -67,6 +70,8 @@ const messages = defineMessages({
|
||||
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
|
||||
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
||||
@ -179,6 +184,7 @@ class Status extends ImmutablePureComponent {
|
||||
state = {
|
||||
fullscreen: false,
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||
loadedStatusId: undefined,
|
||||
};
|
||||
|
||||
@ -197,7 +203,11 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
|
||||
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
|
||||
this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') });
|
||||
this.setState({
|
||||
showMedia: defaultMediaVisibility(nextProps.status),
|
||||
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
|
||||
loadedStatusId: nextProps.status.get('id'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,6 +215,10 @@ class Status extends ImmutablePureComponent {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
}
|
||||
|
||||
handleToggleQuoteMediaVisibility = () => {
|
||||
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||
}
|
||||
|
||||
handleFavouriteClick = (status) => {
|
||||
if (status.get('favourited')) {
|
||||
this.props.dispatch(unfavourite(status));
|
||||
@ -234,6 +248,19 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleQuoteClick = (status) => {
|
||||
let { askReplyConfirmation, dispatch, intl } = this.props;
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.quoteMessage),
|
||||
confirm: intl.formatMessage(messages.quoteConfirm),
|
||||
onConfirm: () => dispatch(quoteCompose(status, this.context.router.history)),
|
||||
}));
|
||||
} else {
|
||||
dispatch(quoteCompose(status, this.context.router.history));
|
||||
}
|
||||
}
|
||||
|
||||
handleModalReblog = (status) => {
|
||||
this.props.dispatch(reblog(status));
|
||||
}
|
||||
@ -322,6 +349,14 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
handleQuoteToggleHidden = (status) => {
|
||||
if (status.get('quote_hidden')) {
|
||||
this.props.dispatch(revealQuote(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(hideQuote(status.get('id')));
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleAll = () => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||
@ -552,9 +587,12 @@ class Status extends ImmutablePureComponent {
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
onToggleHidden={this.handleToggleHidden}
|
||||
onQuoteToggleHidden={this.handleQuoteToggleHidden}
|
||||
domain={domain}
|
||||
showMedia={this.state.showMedia}
|
||||
showQuoteMedia={this.state.showQuoteMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
/>
|
||||
|
||||
@ -564,6 +602,7 @@ class Status extends ImmutablePureComponent {
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onQuote={this.handleQuoteClick}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
|
@ -121,6 +121,7 @@ class Video extends React.PureComponent {
|
||||
autoPlay: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
muted: PropTypes.bool,
|
||||
quote: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -521,7 +522,7 @@ class Video extends React.PureComponent {
|
||||
}
|
||||
|
||||
render () {
|
||||
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash } = this.props;
|
||||
const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash, quote } = this.props;
|
||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const playerStyle = {};
|
||||
@ -535,6 +536,11 @@ class Video extends React.PureComponent {
|
||||
playerStyle.height = height;
|
||||
}
|
||||
|
||||
if (quote && height) {
|
||||
height /= 2;
|
||||
playerStyle.height = height;
|
||||
}
|
||||
|
||||
let preload;
|
||||
|
||||
if (this.props.currentTime || fullscreen || dragging) {
|
||||
|
@ -449,6 +449,14 @@
|
||||
"defaultMessage": "This post cannot be boosted",
|
||||
"id": "status.cannot_reblog"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Quote",
|
||||
"id": "status.quote"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "This post cannot be quoted",
|
||||
"id": "status.cannot_quote"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Favourite",
|
||||
"id": "status.favourite"
|
||||
@ -570,6 +578,14 @@
|
||||
{
|
||||
"defaultMessage": "{name} boosted",
|
||||
"id": "status.reblogged_by"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Muted quote",
|
||||
"id": "status.muted_quote"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Unlisted quote",
|
||||
"id": "status.unlisted_quote"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/components/status.json"
|
||||
@ -2770,6 +2786,14 @@
|
||||
"defaultMessage": "Detailed conversation view",
|
||||
"id": "status.detailed_status"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Quote",
|
||||
"id": "confirmations.quote.confirm"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||
"id": "confirmations.quote.message"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Reply",
|
||||
"id": "confirmations.reply.confirm"
|
||||
|
@ -119,6 +119,8 @@
|
||||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
|
||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||
"confirmations.quote.confirm": "Quote",
|
||||
"confirmations.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
"confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
|
||||
"confirmations.reply.confirm": "Reply",
|
||||
@ -394,6 +396,7 @@
|
||||
"status.block": "Block @{name}",
|
||||
"status.bookmark": "Bookmark",
|
||||
"status.cancel_reblog_private": "Unboost",
|
||||
"status.cannot_quote": "This post cannot be quoted",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.copy": "Copy link to toot",
|
||||
"status.delete": "Delete",
|
||||
@ -408,9 +411,11 @@
|
||||
"status.more": "More",
|
||||
"status.mute": "Mute @{name}",
|
||||
"status.mute_conversation": "Mute conversation",
|
||||
"status.muted_quote": "Muted quote",
|
||||
"status.open": "Expand this toot",
|
||||
"status.pin": "Pin on profile",
|
||||
"status.pinned": "Pinned toot",
|
||||
"status.quote": "Quote",
|
||||
"status.read_more": "Read more",
|
||||
"status.reblog": "Boost",
|
||||
"status.reblog_private": "Boost with original visibility",
|
||||
@ -429,6 +434,7 @@
|
||||
"status.show_more_all": "Show more for all",
|
||||
"status.show_thread": "Show thread",
|
||||
"status.uncached_media_warning": "Not available",
|
||||
"status.unlisted_quote": "Unlisted quote",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
"status.unpin": "Unpin from profile",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
|
@ -119,6 +119,8 @@
|
||||
"confirmations.mute.confirm": "ミュート",
|
||||
"confirmations.mute.explanation": "これにより相手のトゥートと返信は見えなくなりますが、相手はあなたをフォローし続けトゥートを見ることができます。",
|
||||
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
|
||||
"confirmations.quote.confirm": "引用",
|
||||
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
|
||||
"confirmations.redraft.confirm": "削除して下書きに戻す",
|
||||
"confirmations.redraft.message": "本当にこのトゥートを削除して下書きに戻しますか? このトゥートへのお気に入り登録やブーストは失われ、返信は孤立することになります。",
|
||||
"confirmations.reply.confirm": "返信",
|
||||
@ -394,6 +396,7 @@
|
||||
"status.block": "@{name}さんをブロック",
|
||||
"status.bookmark": "ブックマーク",
|
||||
"status.cancel_reblog_private": "ブースト解除",
|
||||
"status.cannot_quote": "この投稿は引用できません",
|
||||
"status.cannot_reblog": "この投稿はブーストできません",
|
||||
"status.copy": "トゥートへのリンクをコピー",
|
||||
"status.delete": "削除",
|
||||
@ -408,9 +411,11 @@
|
||||
"status.more": "もっと見る",
|
||||
"status.mute": "@{name}さんをミュート",
|
||||
"status.mute_conversation": "会話をミュート",
|
||||
"status.muted_quote": "ミュートされた引用",
|
||||
"status.open": "詳細を表示",
|
||||
"status.pin": "プロフィールに固定表示",
|
||||
"status.pinned": "固定されたトゥート",
|
||||
"status.quote": "引用",
|
||||
"status.read_more": "もっと見る",
|
||||
"status.reblog": "ブースト",
|
||||
"status.reblog_private": "ブースト",
|
||||
@ -429,6 +434,7 @@
|
||||
"status.show_more_all": "全て見る",
|
||||
"status.show_thread": "スレッドを表示",
|
||||
"status.uncached_media_warning": "利用できません",
|
||||
"status.unlisted_quote": "未収載の引用",
|
||||
"status.unmute_conversation": "会話のミュートを解除",
|
||||
"status.unpin": "プロフィールへの固定を解除",
|
||||
"suggestions.dismiss": "隠す",
|
||||
|
@ -4,6 +4,8 @@ import {
|
||||
COMPOSE_CHANGE,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_REPLY_CANCEL,
|
||||
COMPOSE_QUOTE,
|
||||
COMPOSE_QUOTE_CANCEL,
|
||||
COMPOSE_DIRECT,
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_SUBMIT_REQUEST,
|
||||
@ -59,6 +61,8 @@ const initialState = ImmutableMap({
|
||||
caretPosition: null,
|
||||
preselectDate: null,
|
||||
in_reply_to: null,
|
||||
quote_from: null,
|
||||
quote_from_url: null,
|
||||
is_composing: false,
|
||||
is_submitting: false,
|
||||
is_changing_upload: false,
|
||||
@ -102,6 +106,7 @@ function clearAll(state) {
|
||||
map.set('is_submitting', false);
|
||||
map.set('is_changing_upload', false);
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', null);
|
||||
map.set('privacy', state.get('default_privacy'));
|
||||
map.set('sensitive', false);
|
||||
map.update('media_attachments', list => list.clear());
|
||||
@ -217,6 +222,17 @@ const expiresInFromExpiresAt = expires_at => {
|
||||
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
||||
};
|
||||
|
||||
const rejectQuoteAltText = html => {
|
||||
const fragment = domParser.parseFromString(html, 'text/html').documentElement;
|
||||
|
||||
const quote_inline = fragment.querySelector('span.quote-inline');
|
||||
if (quote_inline) {
|
||||
quote_inline.remove();
|
||||
}
|
||||
|
||||
return fragment.innerHTML;
|
||||
};
|
||||
|
||||
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
|
||||
prefix = prefix.toLowerCase();
|
||||
if (suggestions.length < 4) {
|
||||
@ -290,9 +306,19 @@ export default function compose(state = initialState, action) {
|
||||
case COMPOSE_COMPOSING_CHANGE:
|
||||
return state.set('is_composing', action.value);
|
||||
case COMPOSE_REPLY:
|
||||
case COMPOSE_QUOTE:
|
||||
return state.withMutations(map => {
|
||||
if (action.type === COMPOSE_REPLY) {
|
||||
map.set('in_reply_to', action.status.get('id'));
|
||||
map.set('quote_from', null);
|
||||
map.set('quote_from_url', null);
|
||||
map.set('text', statusToTextMentions(state, action.status));
|
||||
} else {
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', action.status.get('id'));
|
||||
map.set('quote_from_url', action.status.get('url'));
|
||||
map.set('text', '');
|
||||
}
|
||||
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
||||
map.set('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
@ -308,9 +334,12 @@ export default function compose(state = initialState, action) {
|
||||
}
|
||||
});
|
||||
case COMPOSE_REPLY_CANCEL:
|
||||
case COMPOSE_QUOTE_CANCEL:
|
||||
case COMPOSE_RESET:
|
||||
return state.withMutations(map => {
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', null);
|
||||
map.set('quote_from_url', null);
|
||||
map.set('text', '');
|
||||
map.set('spoiler', false);
|
||||
map.set('spoiler_text', '');
|
||||
@ -399,8 +428,10 @@ export default function compose(state = initialState, action) {
|
||||
}));
|
||||
case REDRAFT:
|
||||
return state.withMutations(map => {
|
||||
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
|
||||
map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
|
||||
map.set('in_reply_to', action.status.get('in_reply_to_id'));
|
||||
map.set('quote_from', action.status.getIn(['quote', 'id']));
|
||||
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
|
||||
map.set('privacy', action.status.get('visibility'));
|
||||
map.set('media_attachments', action.status.get('media_attachments'));
|
||||
map.set('focusDate', new Date());
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_QUOTE,
|
||||
COMPOSE_DIRECT,
|
||||
} from '../actions/compose';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
@ -34,6 +35,7 @@ export default function search(state = initialState, action) {
|
||||
case SEARCH_SHOW:
|
||||
return state.set('hidden', false);
|
||||
case COMPOSE_REPLY:
|
||||
case COMPOSE_QUOTE:
|
||||
case COMPOSE_MENTION:
|
||||
case COMPOSE_DIRECT:
|
||||
return state.set('hidden', true);
|
||||
|
@ -13,6 +13,8 @@ import {
|
||||
STATUS_REVEAL,
|
||||
STATUS_HIDE,
|
||||
STATUS_COLLAPSE,
|
||||
QUOTE_REVEAL,
|
||||
QUOTE_HIDE,
|
||||
} from '../actions/statuses';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||
@ -75,6 +77,14 @@ export default function statuses(state = initialState, action) {
|
||||
});
|
||||
case STATUS_COLLAPSE:
|
||||
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
|
||||
case QUOTE_REVEAL:
|
||||
return state.withMutations(map => {
|
||||
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
|
||||
});
|
||||
case QUOTE_HIDE:
|
||||
return state.withMutations(map => {
|
||||
action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
|
||||
});
|
||||
case TIMELINE_DELETE:
|
||||
return deleteStatus(state, action.id, action.references);
|
||||
default:
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
|
||||
import { me } from '../initial_state';
|
||||
import {reblogRequest} from '../actions/interactions';
|
||||
|
||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
|
||||
@ -88,22 +89,54 @@ export const makeGetStatus = () => {
|
||||
[
|
||||
(state, { id }) => state.getIn(['statuses', id]),
|
||||
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
||||
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', 'account'])]),
|
||||
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', id, 'account'])]),
|
||||
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
||||
(state, { id }) => state.getIn(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', id, 'account']), 'moved'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account']), 'moved'])]),
|
||||
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'quote_id']), 'account']), 'moved'])]),
|
||||
getFiltersRegex,
|
||||
],
|
||||
|
||||
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
|
||||
(statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationshipBase, relationshipReblog, relationshipQuote, movedBase, movedReblog, movedQuote, filtersRegex) => {
|
||||
if (!statusBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
accountBase = accountBase.withMutations(map => {
|
||||
map.set('relationship', relationshipBase);
|
||||
map.set('moved', movedBase);
|
||||
});
|
||||
|
||||
if (statusReblog) {
|
||||
accountReblog = accountReblog.withMutations(map => {
|
||||
map.set('relationship', relationshipReblog);
|
||||
map.set('moved', movedReblog);
|
||||
});
|
||||
statusReblog = statusReblog.set('account', accountReblog);
|
||||
} else {
|
||||
statusReblog = null;
|
||||
}
|
||||
|
||||
if (statusQuote) {
|
||||
accountQuote = accountQuote.withMutations(map => {
|
||||
map.set('relationship', relationshipQuote);
|
||||
map.set('moved', movedQuote);
|
||||
});
|
||||
statusQuote = statusQuote.set('account', accountQuote);
|
||||
} else {
|
||||
statusQuote = null;
|
||||
}
|
||||
|
||||
if (statusReblog && accountReblogQuote) {
|
||||
statusReblog = statusReblog.setIn(['quote', 'account'], accountReblogQuote);
|
||||
}
|
||||
|
||||
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
|
||||
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
||||
return null;
|
||||
@ -114,6 +147,7 @@ export const makeGetStatus = () => {
|
||||
|
||||
return statusBase.withMutations(map => {
|
||||
map.set('reblog', statusReblog);
|
||||
map.set('quote', statusQuote);
|
||||
map.set('account', accountBase);
|
||||
map.set('filtered', filtered);
|
||||
});
|
||||
|
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);
|
@ -291,6 +291,29 @@ function main() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
delegate(document, '.quote-status', 'click', ({ target }) => {
|
||||
if (target.closest('.status__content__spoiler-link') ||
|
||||
target.closest('.media-gallery') ||
|
||||
target.closest('.video-player') ||
|
||||
target.closest('.audio-player')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let url = target.closest('.quote-status').getAttribute('dataurl');
|
||||
if (target.closest('.status__display-name')) {
|
||||
url = target.closest('.status__display-name').getAttribute('href');
|
||||
} else if (target.closest('.status-card')) {
|
||||
url = target.closest('.status-card').getAttribute('href');
|
||||
}
|
||||
|
||||
if (window.location.hostname === url.split('/')[2].split(':')[0]) {
|
||||
window.location.href = url;
|
||||
} else {
|
||||
window.open(url, 'blank');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
loadPolyfills()
|
||||
|
@ -768,6 +768,10 @@
|
||||
min-height: 23px;
|
||||
overflow-y: auto;
|
||||
flex: 0 2 auto;
|
||||
|
||||
&.quote-indicator {
|
||||
background: $success-green;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-indicator__header {
|
||||
@ -948,6 +952,10 @@
|
||||
|
||||
.status__content.status__content--collapsed {
|
||||
max-height: 20px * 15; // 15 lines is roughly above 500 characters
|
||||
|
||||
.quote-status & {
|
||||
max-height: 20px * 5;
|
||||
}
|
||||
}
|
||||
|
||||
.status__content__read-more-button {
|
||||
@ -996,6 +1004,76 @@
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
.quote-status {
|
||||
border: solid 1px $ui-base-lighter-color;
|
||||
border-radius: 4px !important;
|
||||
padding: 5px !important;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
|
||||
.muted-quote,
|
||||
.unlisted-quote button {
|
||||
color: $dark-text-color;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.muted-quote {
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.unlisted-quote button {
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.status__avatar,
|
||||
.detailed-status__display-avatar {
|
||||
height: 18px !important;
|
||||
width: 18px !important;
|
||||
position: absolute;
|
||||
top: 5px !important;
|
||||
left: 5px !important;
|
||||
|
||||
& img {
|
||||
height: 18px !important;
|
||||
width: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detailed-status__display-name {
|
||||
margin-bottom: 0;
|
||||
line-height: unset;
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.status__content__text {
|
||||
p {
|
||||
display: inline;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quote-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status__prepend-icon-wrapper {
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
@ -1018,9 +1096,12 @@
|
||||
padding-left: 68px;
|
||||
position: relative;
|
||||
min-height: 54px;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
cursor: auto;
|
||||
|
||||
&:not(.quote-status) {
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
@supports (-ms-overflow-style: -ms-autohiding-scrollbar) {
|
||||
// Add margin to avoid Edge auto-hiding scrollbar appearing over content.
|
||||
// On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.
|
||||
|
@ -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;
|
||||
|
@ -77,6 +77,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
@mentions = []
|
||||
@params = {}
|
||||
|
||||
process_quote
|
||||
process_status_params
|
||||
process_tags
|
||||
process_audience
|
||||
@ -117,6 +118,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
conversation: conversation_from_uri(@object['conversation']),
|
||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||
poll: process_poll,
|
||||
quote: @quote,
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -521,4 +523,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
def poll_lock_options
|
||||
{ redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
|
||||
end
|
||||
|
||||
def process_quote
|
||||
if (@quote = quote_from_url(@object['quoteUrl']))
|
||||
/<br><br>RE:\s<\/span><a.*<\/a>/.match(@object['content']) do |m|
|
||||
@object['content'] = @object['content'].sub(m[0], "</span><span class=\"quote-inline\">#{m[0].sub(/<\/span>/, '')}</span>")
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def quote_from_url(url)
|
||||
return nil if url.nil?
|
||||
quote = ResolveURLService.new.call(url)
|
||||
status_from_uri(quote.uri) if quote
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
@ -24,6 +24,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
|
@ -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
|
||||
|
@ -39,11 +39,23 @@ class Formatter
|
||||
html = encode_and_link_urls(html, linkable_accounts)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html = simple_format(html, {}, sanitize: false)
|
||||
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
|
||||
html = html.delete("\n")
|
||||
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def format_in_quote(status, **options)
|
||||
html = format(status)
|
||||
return '' if html.empty?
|
||||
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
|
||||
html = doc.css('body')[0].inner_html
|
||||
html.sub!(/^<p>(.+)<\/p>$/, '\1')
|
||||
html = Sanitize.clean(html).delete("\n").truncate(150)
|
||||
html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
|
||||
html.html_safe
|
||||
end
|
||||
|
||||
def reformat(html)
|
||||
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
||||
rescue ArgumentError
|
||||
@ -192,6 +204,12 @@ class Formatter
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
|
||||
def quotify(html, status)
|
||||
url = ActivityPub::TagManager.instance.url_for(status.quote)
|
||||
link = encode_and_link_urls(url)
|
||||
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>~~~~~~~~~~<br/>[#{link}]</span>\\1")
|
||||
end
|
||||
|
||||
def rewrite(text, entities)
|
||||
text = text.to_s
|
||||
|
||||
|
@ -31,6 +31,7 @@ class Sanitize
|
||||
next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
|
||||
next true if e =~ /^(mention|hashtag)$/ # semantic classes
|
||||
next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
|
||||
next true if e =~ /^quote-inline$/ # quote inline classes
|
||||
end
|
||||
|
||||
node['class'] = class_list.join(' ')
|
||||
|
@ -21,6 +21,7 @@
|
||||
# account_id :bigint(8) not null
|
||||
# application_id :bigint(8)
|
||||
# in_reply_to_account_id :bigint(8)
|
||||
# quote_id :bigint(8)
|
||||
# poll_id :bigint(8)
|
||||
# deleted_at :datetime
|
||||
#
|
||||
@ -55,6 +56,7 @@ class Status < ApplicationRecord
|
||||
|
||||
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
|
||||
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
|
||||
belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
|
||||
|
||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||
has_many :bookmarks, inverse_of: :status, dependent: :destroy
|
||||
@ -63,6 +65,7 @@ class Status < ApplicationRecord
|
||||
has_many :mentions, dependent: :destroy, inverse_of: :status
|
||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, dependent: :nullify
|
||||
|
||||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
@ -77,6 +80,7 @@ class Status < ApplicationRecord
|
||||
validates_with DisallowedHashtagsValidator
|
||||
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
||||
validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
|
||||
validates :quote_visibility, inclusion: { in: %w(public unlisted) }, if: :quote?
|
||||
|
||||
accepts_nested_attributes_for :poll
|
||||
|
||||
@ -165,6 +169,14 @@ class Status < ApplicationRecord
|
||||
!reblog_of_id.nil?
|
||||
end
|
||||
|
||||
def quote?
|
||||
!quote_id.nil? && quote
|
||||
end
|
||||
|
||||
def quote_visibility
|
||||
quote&.visibility
|
||||
end
|
||||
|
||||
def within_realtime_window?
|
||||
created_at >= REAL_TIME_WINDOW.ago
|
||||
end
|
||||
@ -225,7 +237,12 @@ class Status < ApplicationRecord
|
||||
fields = [spoiler_text, text]
|
||||
fields += preloadable_poll.options unless preloadable_poll.nil?
|
||||
|
||||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
|
||||
quote_fields = []
|
||||
quote_fields += [quote.spoiler_text, quote.text] if quote?
|
||||
quote_fields += quote.preloadable_poll.options unless quote&.preloadable_poll.nil?
|
||||
|
||||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain) +
|
||||
CustomEmoji.from_text(quote_fields.join(' '), quote&.account&.domain)
|
||||
end
|
||||
|
||||
def replies_count
|
||||
|
@ -9,6 +9,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
:atom_uri, :in_reply_to_atom_uri,
|
||||
:conversation
|
||||
|
||||
attribute :quote_url, if: -> { object.quote? }
|
||||
attribute :misskey_quote, key: :_misskey_quote, if: -> { object.quote? }
|
||||
attribute :misskey_content, key: :_misskey_content, if: -> { object.quote? }
|
||||
attribute :content
|
||||
attribute :content_map, if: :language?
|
||||
|
||||
@ -125,6 +128,16 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
end
|
||||
end
|
||||
|
||||
def quote_url
|
||||
ActivityPub::TagManager.instance.uri_for(object.quote) if object.quote?
|
||||
end
|
||||
|
||||
alias misskey_quote quote_url
|
||||
|
||||
def misskey_content
|
||||
object.text if object.quote?
|
||||
end
|
||||
|
||||
def local?
|
||||
object.account.local?
|
||||
end
|
||||
|
@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||
|
||||
attributes :uri, :title, :short_description, :description, :email,
|
||||
:version, :urls, :stats, :thumbnail,
|
||||
:languages, :registrations, :approval_required, :invites_enabled
|
||||
:languages, :registrations, :approval_required, :invites_enabled,
|
||||
:feature_quote
|
||||
|
||||
has_one :contact_account, serializer: REST::AccountSerializer
|
||||
|
||||
@ -67,6 +68,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||
Setting.min_invite_role == 'user'
|
||||
end
|
||||
|
||||
def feature_quote
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instance_presenter
|
||||
|
@ -15,6 +15,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
attribute :content, unless: :source_requested?
|
||||
attribute :text, if: :source_requested?
|
||||
|
||||
attribute :quote_id, if: -> { object.quote? }
|
||||
|
||||
belongs_to :reblog, serializer: REST::StatusSerializer
|
||||
belongs_to :application, if: :show_application?
|
||||
belongs_to :account, serializer: REST::AccountSerializer
|
||||
@ -39,6 +41,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
object.in_reply_to_account_id&.to_s
|
||||
end
|
||||
|
||||
def quote_id
|
||||
object.quote_id.to_s
|
||||
end
|
||||
|
||||
def current_user?
|
||||
!current_user.nil?
|
||||
end
|
||||
@ -167,3 +173,26 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class REST::NestedQuoteSerializer < REST::StatusSerializer
|
||||
attribute :quote do
|
||||
nil
|
||||
end
|
||||
attribute :quote_muted, if: :current_user?
|
||||
|
||||
def quote_muted
|
||||
if instance_options && instance_options[:account_relationships]
|
||||
!!instance_options[:account_relationships].muting[object.account_id] ||
|
||||
instance_options[:account_relationships].blocking[object.account_id] ||
|
||||
instance_options[:account_relationships].blocked_by[object.account_id] ||
|
||||
instance_options[:account_relationships].domain_blocking[object.account_id]
|
||||
else
|
||||
current_user.account.muting?(object.account) || object.account.blocking?(current_user.account) || current_user.account.blocking?(object.account) || current_user.account.domain_blocking?(object.account.domain)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
class REST::StatusSerializer < ActiveModel::Serializer
|
||||
belongs_to :quote, serializer: REST::NestedQuoteSerializer
|
||||
end
|
||||
|
@ -66,7 +66,7 @@ class FetchLinkCardService < BaseService
|
||||
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
|
||||
else
|
||||
html = Nokogiri::HTML(@status.text)
|
||||
links = html.css('a')
|
||||
links = html.css(':not(.quote-inline) > a')
|
||||
urls = links.map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.compact.map(&:normalize).compact
|
||||
end
|
||||
|
||||
|
@ -20,17 +20,20 @@ class PostStatusService < BaseService
|
||||
# @option [Doorkeeper::Application] :application
|
||||
# @option [String] :idempotency Optional idempotency key
|
||||
# @option [Boolean] :with_rate_limit
|
||||
# @option [Integer] :quote_id
|
||||
# @return [Status]
|
||||
def call(account, options = {})
|
||||
@account = account
|
||||
@options = options
|
||||
@text = @options[:text] || ''
|
||||
@in_reply_to = @options[:thread]
|
||||
@quote_id = @options[:quote_id]
|
||||
|
||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||
|
||||
validate_media!
|
||||
preprocess_attributes!
|
||||
preprocess_quote!
|
||||
|
||||
if scheduled?
|
||||
schedule_status!
|
||||
@ -47,6 +50,19 @@ class PostStatusService < BaseService
|
||||
|
||||
private
|
||||
|
||||
def status_from_uri(uri)
|
||||
ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
end
|
||||
|
||||
def quote_from_url(url)
|
||||
return nil if url.nil?
|
||||
|
||||
quote = ResolveURLService.new.call(url)
|
||||
status_from_uri(quote.uri) if quote
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
|
||||
def preprocess_attributes!
|
||||
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
|
||||
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
|
||||
@ -54,10 +70,21 @@ class PostStatusService < BaseService
|
||||
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
|
||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||
@scheduled_at = nil if scheduled_in_the_past?
|
||||
|
||||
if @quote_id.nil? && (link = @text&.match(/\n\[?(#{FetchLinkCardService::URL_PATTERN})\]?$/))
|
||||
@text = @text&.lines(chomp: true)[0...-1].join("\n") if (@quote_id = quote_from_url(link[1])&.id)
|
||||
end
|
||||
rescue ArgumentError
|
||||
raise ActiveRecord::RecordInvalid
|
||||
end
|
||||
|
||||
def preprocess_quote!
|
||||
if @quote_id.present?
|
||||
quote = Status.find(@quote_id)
|
||||
@quote_id = quote.reblog_of_id.to_s if quote.reblog?
|
||||
end
|
||||
end
|
||||
|
||||
def process_status!
|
||||
# The following transaction block is needed to wrap the UPDATEs to
|
||||
# the media attachments when the status is created
|
||||
@ -164,6 +191,7 @@ class PostStatusService < BaseService
|
||||
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
|
||||
application: @options[:application],
|
||||
rate_limit: @options[:with_rate_limit],
|
||||
quote_id: @quote_id,
|
||||
}.compact
|
||||
end
|
||||
|
||||
|
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 current_account&.user&.setting_auto_play_gif || autoplay
|
||||
= image_tag author.avatar_original_url, alt: '', class: 'u-photo account__avatar'
|
||||
- else
|
||||
= image_tag author.avatar_static_url, alt: '', class: 'u-photo account__avatar'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(author, custom_emojify: true, autoplay: autoplay)
|
||||
|
||||
%span.display-name__account
|
||||
= acct(author)
|
||||
- unless inline
|
||||
= fa_icon('lock') if author.locked?
|
@ -1,45 +1,14 @@
|
||||
.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
|
||||
.p-author.h-card
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
|
||||
.detailed-status__display-avatar
|
||||
- if current_account&.user&.setting_auto_play_gif || autoplay
|
||||
= image_tag status.account.avatar_original_url, alt: '', class: 'account__avatar u-photo'
|
||||
- else
|
||||
= image_tag status.account.avatar_static_url, alt: '', class: 'account__avatar u-photo'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
|
||||
%span.display-name__account
|
||||
= acct(status.account)
|
||||
= fa_icon('lock') if status.account.locked?
|
||||
= render 'statuses/author', author: status.account, autoplay: autoplay, detailed: true
|
||||
|
||||
= account_action_button(status.account)
|
||||
|
||||
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
|
||||
- if status.spoiler_text?
|
||||
%p<
|
||||
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content
|
||||
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
||||
- if status.preloadable_poll
|
||||
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
||||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
||||
= render 'statuses/text', status: status, autoplay: autoplay
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.preview_card
|
||||
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
|
||||
- if status.quote?
|
||||
= render 'statuses/quote', status: status.quote, autoplay: autoplay
|
||||
|
||||
= render 'statuses/media', status: status, autoplay: autoplay, detailed: true
|
||||
|
||||
.detailed-status__meta
|
||||
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
||||
|
18
app/views/statuses/_media.html.haml
Normal file
18
app/views/statuses/_media.html.haml
Normal file
@ -0,0 +1,18 @@
|
||||
:ruby
|
||||
detailed ||= false
|
||||
quote ||= false
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: detailed ? 670 : 610, height: detailed ? 380 : 343, detailed: detailed, inline: true, quote: quote, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: detailed ? 670 : 610, height: detailed ? 380 : 343, quote: quote, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: detailed ? 380 : 343, sensitive: sensitized?(status, current_account), standalone: detailed, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: quote do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.preview_card
|
||||
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true
|
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, autoplay: autoplay, inline: true
|
||||
|
||||
= render 'statuses/text', status: status, autoplay: autoplay
|
||||
|
||||
= render 'statuses/media', status: status, autoplay: autoplay, quote: true
|
@ -9,46 +9,14 @@
|
||||
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
%data.dt-published{ value: status.created_at.to_time.iso8601 }
|
||||
|
||||
.p-author.h-card
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
.status__avatar
|
||||
%div
|
||||
- if current_account&.user&.setting_auto_play_gif || autoplay
|
||||
= image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
|
||||
- else
|
||||
= image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
|
||||
%span.display-name
|
||||
%bdi
|
||||
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: autoplay)
|
||||
= ' '
|
||||
%span.display-name__account
|
||||
= acct(status.account)
|
||||
= fa_icon('lock') if status.account.locked?
|
||||
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
|
||||
- if status.spoiler_text?
|
||||
%p<
|
||||
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content
|
||||
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
||||
- if status.preloadable_poll
|
||||
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
||||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
||||
= render 'statuses/author', author: status.account, autoplay: autoplay
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 610, height: 343, inline: true, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.preview_card
|
||||
= react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
|
||||
= render 'statuses/text', status: status, autoplay: autoplay
|
||||
|
||||
- if status.quote?
|
||||
= render 'statuses/quote', status: status.quote, autoplay: autoplay
|
||||
|
||||
= render 'statuses/media', status: status, autoplay: autoplay
|
||||
|
||||
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id && !hide_show_thread
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
|
||||
|
10
app/views/statuses/_text.html.haml
Normal file
10
app/views/statuses/_text.html.haml
Normal file
@ -0,0 +1,10 @@
|
||||
.status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
|
||||
- if status.spoiler_text?
|
||||
%p<
|
||||
%span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}
|
||||
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||
.e-content
|
||||
= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay)
|
||||
- if status.preloadable_poll
|
||||
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
|
||||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
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
|
@ -803,6 +803,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "application_id"
|
||||
t.bigint "in_reply_to_account_id"
|
||||
t.bigint "quote_id"
|
||||
t.bigint "poll_id"
|
||||
t.datetime "deleted_at"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
@ -810,6 +811,7 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
|
||||
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
||||
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
|
||||
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
|
||||
t.index ["quote_id"], name: "index_statuses_on_quote_id"
|
||||
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
|
||||
t.index ["uri"], name: "index_statuses_on_uri", unique: true
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user