commit
dc591911ce
@ -8,7 +8,8 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
accountIds = @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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -7,7 +7,8 @@ class Api::V1::BookmarksController < Api::BaseController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
accountIds = @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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -7,7 +7,8 @@ class Api::V1::FavouritesController < Api::BaseController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
accountIds = @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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -30,8 +30,9 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
|
|
||||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||||
statuses = [@status] + @context.ancestors + @context.descendants
|
statuses = [@status] + @context.ancestors + @context.descendants
|
||||||
|
accountIds = 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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@ -46,7 +47,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
application: doorkeeper_token.application,
|
application: doorkeeper_token.application,
|
||||||
poll: status_params[:poll],
|
poll: status_params[:poll],
|
||||||
idempotency: request.headers['Idempotency-Key'],
|
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
|
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||||
end
|
end
|
||||||
@ -85,6 +87,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
:spoiler_text,
|
:spoiler_text,
|
||||||
:visibility,
|
:visibility,
|
||||||
:scheduled_at,
|
:scheduled_at,
|
||||||
|
:quote_id,
|
||||||
media_ids: [],
|
media_ids: [],
|
||||||
poll: [
|
poll: [
|
||||||
:multiple,
|
:multiple,
|
||||||
|
@ -7,10 +7,12 @@ class Api::V1::Timelines::HomeController < Api::BaseController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
|
accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||||
|
|
||||||
render json: @statuses,
|
render json: @statuses,
|
||||||
each_serializer: REST::StatusSerializer,
|
each_serializer: REST::StatusSerializer,
|
||||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
|
||||||
|
account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id),
|
||||||
status: account_home_feed.regenerating? ? 206 : 200
|
status: account_home_feed.regenerating? ? 206 : 200
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -9,9 +9,12 @@ class Api::V1::Timelines::ListController < Api::BaseController
|
|||||||
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||||
|
|
||||||
render json: @statuses,
|
render json: @statuses,
|
||||||
each_serializer: REST::StatusSerializer,
|
each_serializer: REST::StatusSerializer,
|
||||||
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
|
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id),
|
||||||
|
account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -6,7 +6,9 @@ class Api::V1::Timelines::PublicController < Api::BaseController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
accountIds = @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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -6,7 +6,8 @@ class Api::V1::Timelines::TagController < Api::BaseController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@statuses = load_statuses
|
@statuses = load_statuses
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
|
accountIds = @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(accountIds, current_user&.account_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { fetchRelationships } from './accounts';
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
import { importFetchedStatuses } from './importer';
|
import { importFetchedStatuses } from './importer';
|
||||||
|
import { uniq } from '../utils/uniq';
|
||||||
|
|
||||||
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
|
export const BOOKMARKED_STATUSES_FETCH_REQUEST = 'BOOKMARKED_STATUSES_FETCH_REQUEST';
|
||||||
export const BOOKMARKED_STATUSES_FETCH_SUCCESS = 'BOOKMARKED_STATUSES_FETCH_SUCCESS';
|
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 => {
|
api(getState).get('/api/v1/bookmarks').then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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));
|
dispatch(fetchBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchBookmarkedStatusesFail(error));
|
dispatch(fetchBookmarkedStatusesFail(error));
|
||||||
@ -61,6 +64,7 @@ export function expandBookmarkedStatuses() {
|
|||||||
api(getState).get(url).then(response => {
|
api(getState).get(url).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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));
|
dispatch(expandBookmarkedStatusesSuccess(response.data, next ? next.uri : null));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandBookmarkedStatusesFail(error));
|
dispatch(expandBookmarkedStatusesFail(error));
|
||||||
|
@ -11,7 +11,7 @@ export function initBoostModal(props) {
|
|||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: BOOSTS_INIT_MODAL,
|
type: BOOSTS_INIT_MODAL,
|
||||||
privacy
|
privacy,
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(openModal('BOOST', props));
|
dispatch(openModal('BOOST', props));
|
||||||
|
@ -20,6 +20,8 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
|||||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||||
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
|
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
|
||||||
|
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
|
||||||
|
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
|
||||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||||
export const COMPOSE_RESET = 'COMPOSE_RESET';
|
export const COMPOSE_RESET = 'COMPOSE_RESET';
|
||||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||||
@ -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() {
|
export function resetCompose() {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_RESET,
|
type: COMPOSE_RESET,
|
||||||
@ -147,6 +166,7 @@ export function submitCompose(routerHistory) {
|
|||||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
|
quote_id: getState().getIn(['compose', 'quote_from'], null),
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { fetchRelationships } from './accounts';
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
import { importFetchedStatuses } from './importer';
|
import { importFetchedStatuses } from './importer';
|
||||||
|
import { uniq } from '../utils/uniq';
|
||||||
|
|
||||||
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
|
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
|
||||||
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
|
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 => {
|
api(getState).get('/api/v1/favourites').then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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));
|
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchFavouritedStatusesFail(error));
|
dispatch(fetchFavouritedStatusesFail(error));
|
||||||
@ -64,6 +67,7 @@ export function expandFavouritedStatuses() {
|
|||||||
api(getState).get(url).then(response => {
|
api(getState).get(url).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
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));
|
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandFavouritedStatusesFail(error));
|
dispatch(expandFavouritedStatusesFail(error));
|
||||||
|
@ -70,6 +70,10 @@ export function importFetchedStatuses(statuses) {
|
|||||||
processStatus(status.reblog);
|
processStatus(status.reblog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.quote && status.quote.id) {
|
||||||
|
processStatus(status.quote);
|
||||||
|
}
|
||||||
|
|
||||||
if (status.poll && status.poll.id) {
|
if (status.poll && status.poll.id) {
|
||||||
pushUnique(polls, normalizePoll(status.poll));
|
pushUnique(polls, normalizePoll(status.poll));
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,10 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||||||
normalStatus.search_index = normalOldStatus.get('search_index');
|
normalStatus.search_index = normalOldStatus.get('search_index');
|
||||||
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
|
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
|
||||||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||||
|
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
|
||||||
normalStatus.hidden = normalOldStatus.get('hidden');
|
normalStatus.hidden = normalOldStatus.get('hidden');
|
||||||
|
normalStatus.quote = normalOldStatus.get('quote');
|
||||||
|
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
|
||||||
} else {
|
} else {
|
||||||
// If the status has a CW but no contents, treat the CW as if it were the
|
// If the status has a CW but no contents, treat the CW as if it were the
|
||||||
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
||||||
@ -77,6 +80,29 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||||
|
|
||||||
|
if (status.quote && status.quote.id) {
|
||||||
|
const quote_spoilerText = status.quote.spoiler_text || '';
|
||||||
|
const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||||
|
|
||||||
|
const quote_emojiMap = makeEmojiMap(normalStatus.quote);
|
||||||
|
|
||||||
|
const quote_account_emojiMap = makeEmojiMap(status.quote.account);
|
||||||
|
const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
|
||||||
|
normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
|
||||||
|
normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
|
||||||
|
let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
|
||||||
|
Array.from(docElem.querySelectorAll('p,br'), line => {
|
||||||
|
let parentNode = line.parentNode;
|
||||||
|
if (line.nextSibling) {
|
||||||
|
parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _contentHtml = docElem.textContent;
|
||||||
|
normalStatus.quote.contentHtml = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
|
||||||
|
normalStatus.quote.spoilerHtml = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
|
||||||
|
normalStatus.quote_hidden = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalStatus;
|
return normalStatus;
|
||||||
|
@ -46,7 +46,7 @@ defineMessages({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
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) {
|
if (accountIds.length > 0) {
|
||||||
dispatch(fetchRelationships(accountIds));
|
dispatch(fetchRelationships(accountIds));
|
||||||
|
@ -30,6 +30,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
|
|||||||
|
|
||||||
export const REDRAFT = 'REDRAFT';
|
export const REDRAFT = 'REDRAFT';
|
||||||
|
|
||||||
|
export const QUOTE_REVEAL = 'QUOTE_REVEAL';
|
||||||
|
export const QUOTE_HIDE = 'QUOTE_HIDE';
|
||||||
|
|
||||||
export function fetchStatusRequest(id, skipLoading) {
|
export function fetchStatusRequest(id, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_REQUEST,
|
type: STATUS_FETCH_REQUEST,
|
||||||
@ -272,3 +275,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
|
|||||||
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 { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
import { submitMarkers } from './markers';
|
import { submitMarkers } from './markers';
|
||||||
import api, { getLinks } from 'mastodon/api';
|
import api, { getLinks } from 'mastodon/api';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import compareId from 'mastodon/compare_id';
|
import compareId from 'mastodon/compare_id';
|
||||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||||
|
import { uniq } from '../utils/uniq';
|
||||||
|
|
||||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||||
@ -39,6 +41,7 @@ export function updateTimeline(timeline, status, accept) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatch(importFetchedStatus(status));
|
dispatch(importFetchedStatus(status));
|
||||||
|
dispatch(fetchRelationships([status.reblog ? status.reblog.account.id : status.account.id, status.quote ? status.quote.account.id : null].filter(function(e){return e})));
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TIMELINE_UPDATE,
|
type: TIMELINE_UPDATE,
|
||||||
@ -111,6 +114,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||||||
api(getState).get(path, { params }).then(response => {
|
api(getState).get(path, { params }).then(response => {
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
dispatch(importFetchedStatuses(response.data));
|
||||||
|
dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id).concat(response.data.map(item => item.quote ? item.quote.account.id : null)).filter(function(e){return e}))));
|
||||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||||
|
|
||||||
if (timelineId === 'home') {
|
if (timelineId === 'home') {
|
||||||
|
@ -236,10 +236,12 @@ class MediaGallery extends React.PureComponent {
|
|||||||
visible: PropTypes.bool,
|
visible: PropTypes.bool,
|
||||||
autoplay: PropTypes.bool,
|
autoplay: PropTypes.bool,
|
||||||
onToggleVisibility: PropTypes.func,
|
onToggleVisibility: PropTypes.func,
|
||||||
|
quote: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
standalone: false,
|
standalone: false,
|
||||||
|
quote: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -310,7 +312,7 @@ class MediaGallery extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
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 { visible } = this.state;
|
||||||
|
|
||||||
const width = this.state.width || defaultWidth;
|
const width = this.state.width || defaultWidth;
|
||||||
@ -332,6 +334,10 @@ class MediaGallery extends React.PureComponent {
|
|||||||
const size = media.take(4).size;
|
const size = media.take(4).size;
|
||||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||||
|
|
||||||
|
if (quote && style.height) {
|
||||||
|
style.height /= 2;
|
||||||
|
}
|
||||||
|
|
||||||
if (standalone && this.isFullSizeEligible()) {
|
if (standalone && this.isFullSizeEligible()) {
|
||||||
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
|
||||||
} else {
|
} else {
|
||||||
|
@ -39,6 +39,9 @@ class Poll extends ImmutablePureComponent {
|
|||||||
|
|
||||||
static getDerivedStateFromProps (props, state) {
|
static getDerivedStateFromProps (props, state) {
|
||||||
const { poll, intl } = props;
|
const { poll, intl } = props;
|
||||||
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const expires_at = poll.get('expires_at');
|
const expires_at = poll.get('expires_at');
|
||||||
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
|
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
|
||||||
return (expired === state.expired) ? null : { expired };
|
return (expired === state.expired) ? null : { expired };
|
||||||
@ -59,7 +62,7 @@ class Poll extends ImmutablePureComponent {
|
|||||||
_setupTimer () {
|
_setupTimer () {
|
||||||
const { poll, intl } = this.props;
|
const { poll, intl } = this.props;
|
||||||
clearTimeout(this._timer);
|
clearTimeout(this._timer);
|
||||||
if (!this.state.expired) {
|
if (!this.state.expired && !!poll) {
|
||||||
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
|
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
|
||||||
this._timer = setTimeout(() => {
|
this._timer = setTimeout(() => {
|
||||||
this.setState({ expired: true });
|
this.setState({ expired: true });
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Avatar from './avatar';
|
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
|
// to use the progress bar to show download progress
|
||||||
import Bundle from '../features/ui/components/bundle';
|
import Bundle from '../features/ui/components/bundle';
|
||||||
|
|
||||||
|
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 {
|
||||||
|
quote_muted: status.get('quote_id', null) ? true : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const id = status.getIn(['quote', 'account', 'id'], null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quote_muted: 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', 'quote_muted']),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||||
const displayName = status.getIn(['account', 'display_name']);
|
const displayName = status.getIn(['account', 'display_name']);
|
||||||
|
|
||||||
@ -59,7 +83,8 @@ const messages = defineMessages({
|
|||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
class Status extends ImmutablePureComponent {
|
class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
@ -70,6 +95,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
account: ImmutablePropTypes.map,
|
account: ImmutablePropTypes.map,
|
||||||
otherAccounts: ImmutablePropTypes.list,
|
otherAccounts: ImmutablePropTypes.list,
|
||||||
|
quote_muted: PropTypes.bool,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
@ -85,6 +111,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
onHeightChange: PropTypes.func,
|
onHeightChange: PropTypes.func,
|
||||||
onToggleHidden: PropTypes.func,
|
onToggleHidden: PropTypes.func,
|
||||||
onToggleCollapsed: PropTypes.func,
|
onToggleCollapsed: PropTypes.func,
|
||||||
|
onQuoteToggleHidden: PropTypes.func,
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
@ -101,6 +128,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
inUse: PropTypes.bool,
|
inUse: PropTypes.bool,
|
||||||
available: PropTypes.bool,
|
available: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
|
contextType: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Avoid checking props that are functions (and whose equality will always
|
// Avoid checking props that are functions (and whose equality will always
|
||||||
@ -112,10 +140,12 @@ class Status extends ImmutablePureComponent {
|
|||||||
'hidden',
|
'hidden',
|
||||||
'unread',
|
'unread',
|
||||||
'pictureInPicture',
|
'pictureInPicture',
|
||||||
|
'quote_muted',
|
||||||
];
|
];
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
showMedia: defaultMediaVisibility(this.props.status),
|
showMedia: defaultMediaVisibility(this.props.status),
|
||||||
|
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||||
statusId: undefined,
|
statusId: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -123,6 +153,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||||
return {
|
return {
|
||||||
showMedia: defaultMediaVisibility(nextProps.status),
|
showMedia: defaultMediaVisibility(nextProps.status),
|
||||||
|
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)),
|
||||||
statusId: nextProps.status.get('id'),
|
statusId: nextProps.status.get('id'),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -134,6 +165,10 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.setState({ showMedia: !this.state.showMedia });
|
this.setState({ showMedia: !this.state.showMedia });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleToggleQuoteMediaVisibility = () => {
|
||||||
|
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||||
|
}
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
this.props.onClick();
|
this.props.onClick();
|
||||||
@ -164,6 +199,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) => {
|
handleAccountClick = (e) => {
|
||||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
const id = e.currentTarget.getAttribute('data-id');
|
const id = e.currentTarget.getAttribute('data-id');
|
||||||
@ -180,6 +224,10 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
|
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleExpandedQuoteToggle = () => {
|
||||||
|
this.props.onQuoteToggleHidden(this._properStatus());
|
||||||
|
};
|
||||||
|
|
||||||
renderLoadingMediaGallery () {
|
renderLoadingMediaGallery () {
|
||||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||||
}
|
}
|
||||||
@ -197,10 +245,19 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
|
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenVideoQuote = (options) => {
|
||||||
|
const status = this._properQuoteStatus();
|
||||||
|
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
|
||||||
|
}
|
||||||
|
|
||||||
handleOpenMedia = (media, index) => {
|
handleOpenMedia = (media, index) => {
|
||||||
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
|
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenMediaQuote = (media, index) => {
|
||||||
|
this.props.onOpenMedia(this._properQuoteStatus().get('id'), media, index);
|
||||||
|
}
|
||||||
|
|
||||||
handleHotkeyOpenMedia = e => {
|
handleHotkeyOpenMedia = e => {
|
||||||
const { onOpenMedia, onOpenVideo } = this.props;
|
const { onOpenMedia, onOpenVideo } = this.props;
|
||||||
const status = this._properStatus();
|
const status = this._properStatus();
|
||||||
@ -275,6 +332,16 @@ class Status extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_properQuoteStatus () {
|
||||||
|
const status = this._properStatus();
|
||||||
|
|
||||||
|
if (status.get('quote', null) !== null && typeof status.get('quote') === 'object') {
|
||||||
|
return status.get('quote');
|
||||||
|
} else {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleRef = c => {
|
handleRef = c => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
}
|
||||||
@ -283,7 +350,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
let media = null;
|
let media = null;
|
||||||
let statusAvatar, prepend, rebloggedByText;
|
let statusAvatar, prepend, rebloggedByText;
|
||||||
|
|
||||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
|
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted } = this.props;
|
||||||
|
|
||||||
let { status, account, ...other } = this.props;
|
let { status, account, ...other } = this.props;
|
||||||
|
|
||||||
@ -354,10 +421,10 @@ class Status extends ImmutablePureComponent {
|
|||||||
status = status.get('reblog');
|
status = status.get('reblog');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status.get('media_attachments').size > 0) {
|
||||||
if (pictureInPicture.get('inUse')) {
|
if (pictureInPicture.get('inUse')) {
|
||||||
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (this.props.muted) {
|
||||||
if (this.props.muted) {
|
|
||||||
media = (
|
media = (
|
||||||
<AttachmentList
|
<AttachmentList
|
||||||
compact
|
compact
|
||||||
@ -459,6 +526,132 @@ class Status extends ImmutablePureComponent {
|
|||||||
|
|
||||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||||
|
|
||||||
|
let quote = null;
|
||||||
|
if (status.get('quote', null) !== null && typeof status.get('quote') === 'object') {
|
||||||
|
let quote_status = status.get('quote');
|
||||||
|
|
||||||
|
let quote_media = null;
|
||||||
|
if (quote_status.get('media_attachments').size > 0) {
|
||||||
|
if (pictureInPicture.get('inUse')) {
|
||||||
|
quote_media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
|
||||||
|
} else if (this.props.muted) {
|
||||||
|
quote_media = (
|
||||||
|
<AttachmentList
|
||||||
|
compact
|
||||||
|
media={quote_status.get('media_attachments')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
|
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
quote_media = (
|
||||||
|
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||||
|
{Component => (
|
||||||
|
<Component
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
poster={attachment.get('preview_url') || quote_status.getIn(['account', 'avatar_static'])}
|
||||||
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
width={this.props.cachedMediaWidth}
|
||||||
|
height={70}
|
||||||
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
|
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
|
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
quote_media = (
|
||||||
|
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||||
|
{Component => (
|
||||||
|
<Component
|
||||||
|
preview={attachment.get('preview_url')}
|
||||||
|
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
|
blurhash={attachment.get('blurhash')}
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
width={this.props.cachedMediaWidth}
|
||||||
|
height={110}
|
||||||
|
inline
|
||||||
|
sensitive={quote_status.get('sensitive')}
|
||||||
|
onOpenVideo={this.handleOpenVideoQuote}
|
||||||
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
|
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||||
|
visible={this.state.showQuoteMedia}
|
||||||
|
onToggleVisibility={this.handleToggleQuoteMediaVisibility}
|
||||||
|
quote
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
quote_media = (
|
||||||
|
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||||
|
{Component => (
|
||||||
|
<Component
|
||||||
|
media={quote_status.get('media_attachments')}
|
||||||
|
sensitive={quote_status.get('sensitive')}
|
||||||
|
height={110}
|
||||||
|
onOpenMedia={this.handleOpenMediaQuote}
|
||||||
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
|
defaultWidth={this.props.cachedMediaWidth}
|
||||||
|
visible={this.state.showQuoteMedia}
|
||||||
|
onToggleVisibility={this.handleToggleQuoteMediaVisibility}
|
||||||
|
quote
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bundle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote_muted) {
|
||||||
|
quote = (
|
||||||
|
<div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
|
||||||
|
<div className={classNames('status__content muted-quote', { 'status__content--with-action': this.context.router })}>
|
||||||
|
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (quote_status.get('visibility') === 'unlisted' && !!contextType && ['public', 'community', 'hashtag'].includes(contextType.split(':', 2)[0])) {
|
||||||
|
quote = (
|
||||||
|
<div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
|
||||||
|
<div className={classNames('status__content unlisted-quote', { 'status__content--with-action': this.context.router })}>
|
||||||
|
<button onClick={this.handleQuoteClick}>
|
||||||
|
<FormattedMessage id='status.unlisted_quote' defaultMessage='Unlisted quote' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
quote = (
|
||||||
|
<div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
|
||||||
|
<div className='status__info'>
|
||||||
|
<a onClick={this.handleAccountClick} target='_blank' data-id={quote_status.getIn(['account', 'id'])} href={quote_status.getIn(['account', 'url'])} title={quote_status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||||
|
<div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
|
||||||
|
<DisplayName account={quote_status.get('account')} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} quote />
|
||||||
|
{quote_media}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (quote_muted) {
|
||||||
|
quote = (
|
||||||
|
<div className={classNames('quote-status', { muted: this.props.muted })}>
|
||||||
|
<div className={classNames('status__content muted-quote', { 'status__content--with-action': this.context.router })}>
|
||||||
|
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||||
@ -483,6 +676,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
|
<StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} showThread={showThread} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
|
||||||
|
|
||||||
|
{quote}
|
||||||
{media}
|
{media}
|
||||||
|
|
||||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||||
|
@ -23,7 +23,9 @@ const messages = defineMessages({
|
|||||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||||
|
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||||
|
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||||
@ -61,6 +63,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onReblog: PropTypes.func,
|
onReblog: PropTypes.func,
|
||||||
|
onQuote: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
onDirect: PropTypes.func,
|
onDirect: PropTypes.func,
|
||||||
onMention: PropTypes.func,
|
onMention: PropTypes.func,
|
||||||
@ -137,6 +140,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
this.props.onBookmark(this.props.status);
|
this.props.onBookmark(this.props.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick = () => {
|
||||||
|
this.props.onQuote(this.props.status, this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
handleDeleteClick = () => {
|
handleDeleteClick = () => {
|
||||||
this.props.onDelete(this.props.status, this.context.router.history);
|
this.props.onDelete(this.props.status, this.context.router.history);
|
||||||
}
|
}
|
||||||
@ -334,7 +341,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='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={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 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={anonymousAccess || !publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
|
||||||
{shareButton}
|
{shareButton}
|
||||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
<IconButton className='status__action-bar-button' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
collapsable: PropTypes.bool,
|
collapsable: PropTypes.bool,
|
||||||
onCollapsedToggle: PropTypes.func,
|
onCollapsedToggle: PropTypes.func,
|
||||||
|
quote: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -125,6 +126,15 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onQuoteClick = (statusId, e) => {
|
||||||
|
let statusUrl = `/statuses/${statusId}`;
|
||||||
|
|
||||||
|
if (this.context.router && e.button === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(statusUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseDown = (e) => {
|
handleMouseDown = (e) => {
|
||||||
this.startXY = [e.clientX, e.clientY];
|
this.startXY = [e.clientX, e.clientY];
|
||||||
}
|
}
|
||||||
@ -168,11 +178,12 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status } = this.props;
|
const { status, quote } = this.props;
|
||||||
|
|
||||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||||
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
const renderViewThread = this.props.showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||||
|
const renderShowPoll = !!status.get('poll');
|
||||||
|
|
||||||
const content = { __html: status.get('contentHtml') };
|
const content = { __html: status.get('contentHtml') };
|
||||||
const spoilerContent = { __html: status.get('spoilerHtml') };
|
const spoilerContent = { __html: status.get('spoilerHtml') };
|
||||||
@ -194,6 +205,16 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showPollButton = (
|
||||||
|
<button className='status__content__read-more-button' onClick={this.props.onClick} key='show-poll'>
|
||||||
|
<FormattedMessage id='status.show_poll' defaultMessage='Show poll' /><Icon id='angle-right' fixedWidth />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pollContainer = (
|
||||||
|
<PollContainer pollId={status.get('poll')} />
|
||||||
|
);
|
||||||
|
|
||||||
if (status.get('spoiler_text').length > 0) {
|
if (status.get('spoiler_text').length > 0) {
|
||||||
let mentionsPlaceholder = '';
|
let mentionsPlaceholder = '';
|
||||||
|
|
||||||
@ -221,7 +242,7 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
|
|
||||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} dangerouslySetInnerHTML={content} />
|
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{!hidden && renderShowPoll && quote ? showPollButton : pollContainer}
|
||||||
|
|
||||||
{renderViewThread && showThreadButton}
|
{renderViewThread && showThreadButton}
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +252,7 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{renderShowPoll && quote ? showPollButton : pollContainer}
|
||||||
|
|
||||||
{renderViewThread && showThreadButton}
|
{renderViewThread && showThreadButton}
|
||||||
</div>,
|
</div>,
|
||||||
@ -247,7 +268,7 @@ export default class StatusContent extends React.PureComponent {
|
|||||||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
{renderShowPoll && quote ? showPollButton : pollContainer}
|
||||||
|
|
||||||
{renderViewThread && showThreadButton}
|
{renderViewThread && showThreadButton}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,7 @@ import Status from '../components/status';
|
|||||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||||
import {
|
import {
|
||||||
replyCompose,
|
replyCompose,
|
||||||
|
quoteCompose,
|
||||||
mentionCompose,
|
mentionCompose,
|
||||||
directCompose,
|
directCompose,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
@ -24,6 +25,8 @@ import {
|
|||||||
hideStatus,
|
hideStatus,
|
||||||
revealStatus,
|
revealStatus,
|
||||||
toggleStatusCollapse,
|
toggleStatusCollapse,
|
||||||
|
hideQuote,
|
||||||
|
revealQuote,
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import {
|
import {
|
||||||
unmuteAccount,
|
unmuteAccount,
|
||||||
@ -50,6 +53,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.' },
|
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' },
|
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?' },
|
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' },
|
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,6 +104,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) {
|
onFavourite (status) {
|
||||||
if (status.get('favourited')) {
|
if (status.get('favourited')) {
|
||||||
dispatch(unfavourite(status));
|
dispatch(unfavourite(status));
|
||||||
@ -215,6 +236,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
|
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onQuoteToggleHidden (status) {
|
||||||
|
if (status.get('quote_hidden')) {
|
||||||
|
dispatch(revealQuote(status.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(hideQuote(status.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
||||||
|
@ -47,7 +47,9 @@ export default class TimelineContainer extends React.PureComponent {
|
|||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
<div className='standalone-timeline'>
|
||||||
{timeline}
|
{timeline}
|
||||||
|
</div>
|
||||||
|
|
||||||
{ReactDOM.createPortal(
|
{ReactDOM.createPortal(
|
||||||
<ModalContainer />,
|
<ModalContainer />,
|
||||||
|
@ -4,6 +4,7 @@ import Button from '../../../components/button';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||||
|
import QuoteIndicatorContainer from '../containers/quote_indicator_container';
|
||||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||||
import PollButtonContainer from '../containers/poll_button_container';
|
import PollButtonContainer from '../containers/poll_button_container';
|
||||||
@ -220,6 +221,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||||||
<WarningContainer />
|
<WarningContainer />
|
||||||
|
|
||||||
<ReplyIndicatorContainer />
|
<ReplyIndicatorContainer />
|
||||||
|
<QuoteIndicatorContainer />
|
||||||
|
|
||||||
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
|
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
|
||||||
<AutosuggestInput
|
<AutosuggestInput
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Avatar from '../../../components/avatar';
|
||||||
|
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 AttachmentList from 'mastodon/components/attachment_list';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class QuoteIndicator extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
status: ImmutablePropTypes.map,
|
||||||
|
onCancel: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
this.props.onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAccountClick = (e) => {
|
||||||
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { status, intl } = this.props;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = { __html: status.get('contentHtml') };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='quote-indicator'>
|
||||||
|
<div className='quote-indicator__header'>
|
||||||
|
<div className='quote-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
|
||||||
|
|
||||||
|
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='quote-indicator__display-name'>
|
||||||
|
<div className='quote-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
|
||||||
|
<DisplayName account={status.get('account')} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='quote-indicator__content' dir='auto' dangerouslySetInnerHTML={content} />
|
||||||
|
|
||||||
|
{status.get('media_attachments').size > 0 && (
|
||||||
|
<AttachmentList
|
||||||
|
compact
|
||||||
|
media={status.get('media_attachments')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { cancelQuoteCompose } from '../../../actions/compose';
|
||||||
|
import { makeGetStatus } from '../../../selectors';
|
||||||
|
import QuoteIndicator from '../components/quote_indicator';
|
||||||
|
|
||||||
|
const makeMapStateToProps = () => {
|
||||||
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStateToProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
||||||
|
onCancel () {
|
||||||
|
dispatch(cancelQuoteCompose());
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);
|
@ -76,7 +76,7 @@ class FollowRecommendations extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<div className='scrollable'>
|
<div className='scrollable follow-recommendations-container'>
|
||||||
<div className='column-title'>
|
<div className='column-title'>
|
||||||
<Logo />
|
<Logo />
|
||||||
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
|
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
|
||||||
|
@ -2,7 +2,6 @@ import { connect } from 'react-redux';
|
|||||||
import { makeGetNotification, makeGetStatus } from '../../../selectors';
|
import { makeGetNotification, makeGetStatus } from '../../../selectors';
|
||||||
import Notification from '../components/notification';
|
import Notification from '../components/notification';
|
||||||
import { initBoostModal } from '../../../actions/boosts';
|
import { initBoostModal } from '../../../actions/boosts';
|
||||||
import { openModal } from '../../../actions/modal';
|
|
||||||
import { mentionCompose } from '../../../actions/compose';
|
import { mentionCompose } from '../../../actions/compose';
|
||||||
import {
|
import {
|
||||||
reblog,
|
reblog,
|
||||||
|
@ -7,7 +7,7 @@ import IconButton from 'mastodon/components/icon_button';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { me, boostModal } from 'mastodon/initial_state';
|
import { me, boostModal } from 'mastodon/initial_state';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
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 { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
import { initBoostModal } from 'mastodon/actions/boosts';
|
import { initBoostModal } from 'mastodon/actions/boosts';
|
||||||
@ -20,9 +20,13 @@ const messages = defineMessages({
|
|||||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||||
|
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||||
|
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
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?' },
|
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' },
|
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,6 +123,31 @@ class Footer extends ImmutablePureComponent {
|
|||||||
router.history.push(`/statuses/${status.get('id')}`);
|
router.history.push(`/statuses/${status.get('id')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, intl, withOpenButton } = this.props;
|
const { status, intl, withOpenButton } = this.props;
|
||||||
|
|
||||||
@ -152,6 +181,7 @@ class Footer 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='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} counter={status.get('reblogs_count')} />
|
<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} counter={status.get('reblogs_count')} />
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||||
|
<IconButton className='status__action-bar-button' disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
|
||||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
|
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -17,7 +17,9 @@ const messages = defineMessages({
|
|||||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||||
|
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||||
|
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
@ -56,6 +58,7 @@ class ActionBar extends React.PureComponent {
|
|||||||
relationship: ImmutablePropTypes.map,
|
relationship: ImmutablePropTypes.map,
|
||||||
onReply: PropTypes.func.isRequired,
|
onReply: PropTypes.func.isRequired,
|
||||||
onReblog: PropTypes.func.isRequired,
|
onReblog: PropTypes.func.isRequired,
|
||||||
|
onQuote: PropTypes.func.isRequired,
|
||||||
onFavourite: PropTypes.func.isRequired,
|
onFavourite: PropTypes.func.isRequired,
|
||||||
onBookmark: PropTypes.func.isRequired,
|
onBookmark: PropTypes.func.isRequired,
|
||||||
onDelete: PropTypes.func.isRequired,
|
onDelete: PropTypes.func.isRequired,
|
||||||
@ -82,6 +85,10 @@ class ActionBar extends React.PureComponent {
|
|||||||
this.props.onReblog(this.props.status, e);
|
this.props.onReblog(this.props.status, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleQuoteClick = () => {
|
||||||
|
this.props.onQuote(this.props.status, this.context.router.history);
|
||||||
|
}
|
||||||
|
|
||||||
handleFavouriteClick = () => {
|
handleFavouriteClick = () => {
|
||||||
this.props.onFavourite(this.props.status);
|
this.props.onFavourite(this.props.status);
|
||||||
}
|
}
|
||||||
@ -277,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 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={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 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={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
|
||||||
{shareButton}
|
{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>
|
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||||
|
|
||||||
|
@ -60,6 +60,10 @@ const addAutoPlay = html => {
|
|||||||
|
|
||||||
export default class Card extends React.PureComponent {
|
export default class Card extends React.PureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
card: ImmutablePropTypes.map,
|
card: ImmutablePropTypes.map,
|
||||||
maxDescription: PropTypes.number,
|
maxDescription: PropTypes.number,
|
||||||
@ -68,6 +72,7 @@ export default class Card extends React.PureComponent {
|
|||||||
defaultWidth: PropTypes.number,
|
defaultWidth: PropTypes.number,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
sensitive: PropTypes.bool,
|
sensitive: PropTypes.bool,
|
||||||
|
quote: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -184,7 +189,7 @@ export default class Card extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { card, maxDescription, compact } = this.props;
|
const { card, maxDescription, compact, quote } = this.props;
|
||||||
const { width, embedded, revealed } = this.state;
|
const { width, embedded, revealed } = this.state;
|
||||||
|
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
@ -197,7 +202,11 @@ export default class Card extends React.PureComponent {
|
|||||||
const className = classnames('status-card', { horizontal, compact, interactive });
|
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 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 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 = (
|
const description = (
|
||||||
<div className='status-card__content'>
|
<div className='status-card__content'>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Avatar from '../../../components/avatar';
|
import Avatar from '../../../components/avatar';
|
||||||
@ -6,7 +7,7 @@ import DisplayName from '../../../components/display_name';
|
|||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
|
import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl';
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Video from '../../video';
|
import Video from '../../video';
|
||||||
@ -24,7 +25,31 @@ const messages = defineMessages({
|
|||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
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 {
|
||||||
|
quote_muted: status.get('quote_id', null) ? true : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const id = status.getIn(['quote', 'account', 'id'], null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quote_muted: 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', 'quote_muted']),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
class DetailedStatus extends ImmutablePureComponent {
|
class DetailedStatus extends ImmutablePureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
@ -33,8 +58,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
|
quote_muted: PropTypes.bool,
|
||||||
onOpenMedia: PropTypes.func.isRequired,
|
onOpenMedia: PropTypes.func.isRequired,
|
||||||
onOpenVideo: PropTypes.func.isRequired,
|
onOpenVideo: PropTypes.func.isRequired,
|
||||||
|
onOpenMediaQuote: PropTypes.func.isRequired,
|
||||||
|
onOpenVideoQuote: PropTypes.func.isRequired,
|
||||||
onToggleHidden: PropTypes.func.isRequired,
|
onToggleHidden: PropTypes.func.isRequired,
|
||||||
measureHeight: PropTypes.bool,
|
measureHeight: PropTypes.bool,
|
||||||
onHeightChange: PropTypes.func,
|
onHeightChange: PropTypes.func,
|
||||||
@ -46,6 +74,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
available: PropTypes.bool,
|
available: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
onToggleMediaVisibility: PropTypes.func,
|
onToggleMediaVisibility: PropTypes.func,
|
||||||
|
onQuoteToggleHidden: PropTypes.func.isRequired,
|
||||||
|
showQuoteMedia: PropTypes.bool,
|
||||||
|
onToggleQuoteMediaVisibility: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@ -54,8 +85,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
|
|
||||||
handleAccountClick = (e) => {
|
handleAccountClick = (e) => {
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
|
||||||
|
const id = e.currentTarget.getAttribute('data-id');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
this.context.router.history.push(`/accounts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -65,6 +97,10 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
|
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenVideoQuote = (options) => {
|
||||||
|
this.props.onOpenVideoQuote(this.props.status.getIn(['quote', 'media_attachments', 0]), options);
|
||||||
|
}
|
||||||
|
|
||||||
handleExpandedToggle = () => {
|
handleExpandedToggle = () => {
|
||||||
this.props.onToggleHidden(this.props.status);
|
this.props.onToggleHidden(this.props.status);
|
||||||
}
|
}
|
||||||
@ -102,8 +138,22 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'])}`);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||||
|
const quote_muted = this.props.quote_muted
|
||||||
const outerStyle = { boxSizing: 'border-box' };
|
const outerStyle = { boxSizing: 'border-box' };
|
||||||
const { intl, compact, pictureInPicture } = this.props;
|
const { intl, compact, pictureInPicture } = this.props;
|
||||||
|
|
||||||
@ -121,6 +171,95 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
outerStyle.height = `${this.state.height}px`;
|
outerStyle.height = `${this.state.height}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let quote = null;
|
||||||
|
if (status.get('quote', null) !== null) {
|
||||||
|
let quote_status = status.get('quote');
|
||||||
|
|
||||||
|
let quote_media = null;
|
||||||
|
if (quote_status.get('media_attachments').size > 0) {
|
||||||
|
|
||||||
|
if (quote_status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||||
|
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
quote_media = (
|
||||||
|
<Audio
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
|
poster={attachment.get('preview_url') || quote_status.getIn(['account', 'avatar_static'])}
|
||||||
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (quote_status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
|
const attachment = quote_status.getIn(['media_attachments', 0]);
|
||||||
|
|
||||||
|
quote_media = (
|
||||||
|
<Video
|
||||||
|
preview={attachment.get('preview_url')}
|
||||||
|
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
|
blurhash={attachment.get('blurhash')}
|
||||||
|
src={attachment.get('url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
width={300}
|
||||||
|
height={150}
|
||||||
|
inline
|
||||||
|
onOpenVideo={this.handleOpenVideoQuote}
|
||||||
|
sensitive={quote_status.get('sensitive')}
|
||||||
|
visible={this.props.showQuoteMedia}
|
||||||
|
onToggleVisibility={this.props.onToggleQuoteMediaVisibility}
|
||||||
|
quote
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
quote_media = (
|
||||||
|
<MediaGallery
|
||||||
|
standalone
|
||||||
|
sensitive={quote_status.get('sensitive')}
|
||||||
|
media={quote_status.get('media_attachments')}
|
||||||
|
height={300}
|
||||||
|
onOpenMedia={this.props.onOpenMediaQuote}
|
||||||
|
visible={this.props.showQuoteMedia}
|
||||||
|
onToggleVisibility={this.props.onToggleQuoteMediaVisibility}
|
||||||
|
quote
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote_muted) {
|
||||||
|
quote = (
|
||||||
|
<div className='quote-status' data-id={quote_status.get('id')} dataurl={quote_status.get('url')}>
|
||||||
|
<div className='status__content muted-quote'>
|
||||||
|
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
quote = (
|
||||||
|
<div className='quote-status' data-id={quote_status.get('id')} dataurl={quote_status.get('url')}>
|
||||||
|
<a href={quote_status.getIn(['account', 'url'])} onClick={this.handleAccountClick} data-id={quote_status.getIn(['account', 'id'])} className='detailed-status__display-name'>
|
||||||
|
<div className='detailed-status__display-avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
|
||||||
|
<DisplayName account={quote_status.get('account')} localDomain={this.props.domain} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} quote />
|
||||||
|
{quote_media}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (quote_muted) {
|
||||||
|
quote = (
|
||||||
|
<div className={classNames('quote-status', { muted: this.props.muted })} data-id={quote_status.get('id')} dataurl={quote_status.get('url')}>
|
||||||
|
<div className={classNames('status__content muted-quote', { 'status__content--with-action': this.context.router })}>
|
||||||
|
<FormattedMessage id='status.muted_quote' defaultMessage='Muted quote' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (pictureInPicture.get('inUse')) {
|
if (pictureInPicture.get('inUse')) {
|
||||||
media = <PictureInPicturePlaceholder />;
|
media = <PictureInPicturePlaceholder />;
|
||||||
} else if (status.get('media_attachments').size > 0) {
|
} else if (status.get('media_attachments').size > 0) {
|
||||||
@ -247,6 +386,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
|
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
|
||||||
|
|
||||||
|
{quote}
|
||||||
{media}
|
{media}
|
||||||
|
|
||||||
<div className='detailed-status__meta'>
|
<div className='detailed-status__meta'>
|
||||||
|
@ -20,6 +20,8 @@ import {
|
|||||||
deleteStatus,
|
deleteStatus,
|
||||||
hideStatus,
|
hideStatus,
|
||||||
revealStatus,
|
revealStatus,
|
||||||
|
hideQuote,
|
||||||
|
revealQuote,
|
||||||
} from '../../../actions/statuses';
|
} from '../../../actions/statuses';
|
||||||
import { initMuteModal } from '../../../actions/mutes';
|
import { initMuteModal } from '../../../actions/mutes';
|
||||||
import { initBlockModal } from '../../../actions/blocks';
|
import { initBlockModal } from '../../../actions/blocks';
|
||||||
@ -136,6 +138,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
dispatch(openModal('VIDEO', { media, options }));
|
dispatch(openModal('VIDEO', { media, options }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onOpenMediaQuote (media, index) {
|
||||||
|
dispatch(openModal('MEDIA', { media, index }));
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpenVideoQuote (media, options) {
|
||||||
|
dispatch(openModal('VIDEO', { media, options }));
|
||||||
|
},
|
||||||
|
|
||||||
onBlock (status) {
|
onBlock (status) {
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
dispatch(initBlockModal(account));
|
dispatch(initBlockModal(account));
|
||||||
@ -165,6 +175,13 @@ 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));
|
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
||||||
|
@ -22,6 +22,7 @@ import {
|
|||||||
} from '../../actions/interactions';
|
} from '../../actions/interactions';
|
||||||
import {
|
import {
|
||||||
replyCompose,
|
replyCompose,
|
||||||
|
quoteCompose,
|
||||||
mentionCompose,
|
mentionCompose,
|
||||||
directCompose,
|
directCompose,
|
||||||
} from '../../actions/compose';
|
} from '../../actions/compose';
|
||||||
@ -31,6 +32,8 @@ import {
|
|||||||
deleteStatus,
|
deleteStatus,
|
||||||
hideStatus,
|
hideStatus,
|
||||||
revealStatus,
|
revealStatus,
|
||||||
|
hideQuote,
|
||||||
|
revealQuote,
|
||||||
} from '../../actions/statuses';
|
} from '../../actions/statuses';
|
||||||
import {
|
import {
|
||||||
unblockAccount,
|
unblockAccount,
|
||||||
@ -68,6 +71,8 @@ const messages = defineMessages({
|
|||||||
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
|
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
|
||||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
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?' },
|
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' },
|
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -180,6 +185,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
state = {
|
state = {
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
showMedia: defaultMediaVisibility(this.props.status),
|
showMedia: defaultMediaVisibility(this.props.status),
|
||||||
|
showQuoteMedia: defaultMediaVisibility(this.props.status ? this.props.status.get('quote', null) : null),
|
||||||
loadedStatusId: undefined,
|
loadedStatusId: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -198,7 +204,8 @@ class Status extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) {
|
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), loadedStatusId: nextProps.status.get('id'),
|
||||||
|
showQuoteMedia: defaultMediaVisibility(nextProps.status.get('quote', null)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,6 +213,10 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.setState({ showMedia: !this.state.showMedia });
|
this.setState({ showMedia: !this.state.showMedia });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleToggleQuoteMediaVisibility = () => {
|
||||||
|
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
|
||||||
|
}
|
||||||
|
|
||||||
handleFavouriteClick = (status) => {
|
handleFavouriteClick = (status) => {
|
||||||
if (status.get('favourited')) {
|
if (status.get('favourited')) {
|
||||||
this.props.dispatch(unfavourite(status));
|
this.props.dispatch(unfavourite(status));
|
||||||
@ -259,6 +270,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleDeleteClick = (status, history, withRedraft = false) => {
|
handleDeleteClick = (status, history, withRedraft = false) => {
|
||||||
const { dispatch, intl } = this.props;
|
const { dispatch, intl } = this.props;
|
||||||
|
|
||||||
@ -289,6 +313,14 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
|
this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenMediaQuote = (media, index) => {
|
||||||
|
this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.getIn(['quote', 'id']), media, index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenVideoQuote = (media, options) => {
|
||||||
|
this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.getIn(['quote', 'id']), media, options }));
|
||||||
|
}
|
||||||
|
|
||||||
handleHotkeyOpenMedia = e => {
|
handleHotkeyOpenMedia = e => {
|
||||||
const { status } = this.props;
|
const { status } = this.props;
|
||||||
|
|
||||||
@ -323,6 +355,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 = () => {
|
handleToggleAll = () => {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||||
@ -552,11 +592,16 @@ class Status extends ImmutablePureComponent {
|
|||||||
status={status}
|
status={status}
|
||||||
onOpenVideo={this.handleOpenVideo}
|
onOpenVideo={this.handleOpenVideo}
|
||||||
onOpenMedia={this.handleOpenMedia}
|
onOpenMedia={this.handleOpenMedia}
|
||||||
|
onOpenVideoQuote={this.handleOpenVideoQuote}
|
||||||
|
onOpenMediaQuote={this.handleOpenMediaQuote}
|
||||||
onToggleHidden={this.handleToggleHidden}
|
onToggleHidden={this.handleToggleHidden}
|
||||||
domain={domain}
|
domain={domain}
|
||||||
showMedia={this.state.showMedia}
|
showMedia={this.state.showMedia}
|
||||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||||
pictureInPicture={pictureInPicture}
|
pictureInPicture={pictureInPicture}
|
||||||
|
onQuoteToggleHidden={this.handleQuoteToggleHidden}
|
||||||
|
showQuoteMedia={this.state.showQuoteMedia}
|
||||||
|
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionBar
|
<ActionBar
|
||||||
@ -566,6 +611,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
onFavourite={this.handleFavouriteClick}
|
onFavourite={this.handleFavouriteClick}
|
||||||
onReblog={this.handleReblogClick}
|
onReblog={this.handleReblogClick}
|
||||||
onBookmark={this.handleBookmarkClick}
|
onBookmark={this.handleBookmarkClick}
|
||||||
|
onQuote={this.handleQuoteClick}
|
||||||
onDelete={this.handleDeleteClick}
|
onDelete={this.handleDeleteClick}
|
||||||
onDirect={this.handleDirectClick}
|
onDirect={this.handleDirectClick}
|
||||||
onMention={this.handleMentionClick}
|
onMention={this.handleMentionClick}
|
||||||
|
@ -309,7 +309,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
|
<div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
|
||||||
<div className='report-modal__target'>
|
<div className='report-modal__target'>
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
|
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
|
||||||
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
|
<FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@ class ReportModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal report-modal'>
|
<div className='modal-root__modal report-modal'>
|
||||||
<div className='report-modal__target'>
|
<div className='report-modal__target'>
|
||||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
|
<IconButton className='report-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
|
||||||
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
|
<FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -122,6 +122,7 @@ class Video extends React.PureComponent {
|
|||||||
volume: PropTypes.number,
|
volume: PropTypes.number,
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
componetIndex: PropTypes.number,
|
componetIndex: PropTypes.number,
|
||||||
|
quote: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -523,7 +524,7 @@ class Video extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
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 { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||||
const playerStyle = {};
|
const playerStyle = {};
|
||||||
@ -537,6 +538,11 @@ class Video extends React.PureComponent {
|
|||||||
playerStyle.height = height;
|
playerStyle.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (quote && height) {
|
||||||
|
height /= 2;
|
||||||
|
playerStyle.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
let preload;
|
let preload;
|
||||||
|
|
||||||
if (this.props.currentTime || fullscreen || dragging) {
|
if (this.props.currentTime || fullscreen || dragging) {
|
||||||
|
@ -449,6 +449,18 @@
|
|||||||
"defaultMessage": "This post cannot be boosted",
|
"defaultMessage": "This post cannot be boosted",
|
||||||
"id": "status.cannot_reblog"
|
"id": "status.cannot_reblog"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Quote",
|
||||||
|
"id": "status.quote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Unlisted quote",
|
||||||
|
"id": "status.unlisted_quote"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Muted quote",
|
||||||
|
"id": "status.muted_quote"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Favourite",
|
"defaultMessage": "Favourite",
|
||||||
"id": "status.favourite"
|
"id": "status.favourite"
|
||||||
@ -1229,6 +1241,15 @@
|
|||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json"
|
"path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"descriptors": [
|
||||||
|
{
|
||||||
|
"defaultMessage": "Cancel",
|
||||||
|
"id": "quote_indicator.cancel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"path": "app/javascript/mastodon/features/compose/components/quote_indicator.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
{
|
{
|
||||||
|
@ -120,6 +120,8 @@
|
|||||||
"confirmations.mute.confirm": "Mute",
|
"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.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.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.confirm": "Delete & redraft",
|
||||||
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
|
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
|
||||||
"confirmations.reply.confirm": "Reply",
|
"confirmations.reply.confirm": "Reply",
|
||||||
@ -359,6 +361,7 @@
|
|||||||
"privacy.public.short": "Public",
|
"privacy.public.short": "Public",
|
||||||
"privacy.unlisted.long": "Visible for all, but not in public timelines",
|
"privacy.unlisted.long": "Visible for all, but not in public timelines",
|
||||||
"privacy.unlisted.short": "Unlisted",
|
"privacy.unlisted.short": "Unlisted",
|
||||||
|
"quote_indicator.cancel": "Cancel",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"regeneration_indicator.label": "Loading…",
|
"regeneration_indicator.label": "Loading…",
|
||||||
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
|
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
|
||||||
@ -392,6 +395,7 @@
|
|||||||
"status.block": "Block @{name}",
|
"status.block": "Block @{name}",
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
"status.cancel_reblog_private": "Unboost",
|
"status.cancel_reblog_private": "Unboost",
|
||||||
|
"status.cannot_quote": "This post cannot be quoted",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.copy": "Copy link to post",
|
"status.copy": "Copy link to post",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
@ -406,9 +410,11 @@
|
|||||||
"status.more": "More",
|
"status.more": "More",
|
||||||
"status.mute": "Mute @{name}",
|
"status.mute": "Mute @{name}",
|
||||||
"status.mute_conversation": "Mute conversation",
|
"status.mute_conversation": "Mute conversation",
|
||||||
|
"status.muted_quote": "Muted quote",
|
||||||
"status.open": "Expand this post",
|
"status.open": "Expand this post",
|
||||||
"status.pin": "Pin on profile",
|
"status.pin": "Pin on profile",
|
||||||
"status.pinned": "Pinned post",
|
"status.pinned": "Pinned post",
|
||||||
|
"status.quote": "Quote",
|
||||||
"status.read_more": "Read more",
|
"status.read_more": "Read more",
|
||||||
"status.reblog": "Boost",
|
"status.reblog": "Boost",
|
||||||
"status.reblog_private": "Boost with original visibility",
|
"status.reblog_private": "Boost with original visibility",
|
||||||
@ -425,8 +431,10 @@
|
|||||||
"status.show_less_all": "Show less for all",
|
"status.show_less_all": "Show less for all",
|
||||||
"status.show_more": "Show more",
|
"status.show_more": "Show more",
|
||||||
"status.show_more_all": "Show more for all",
|
"status.show_more_all": "Show more for all",
|
||||||
|
"status.show_poll": "Show poll",
|
||||||
"status.show_thread": "Show thread",
|
"status.show_thread": "Show thread",
|
||||||
"status.uncached_media_warning": "Not available",
|
"status.uncached_media_warning": "Not available",
|
||||||
|
"status.unlisted_quote": "Unlisted quote",
|
||||||
"status.unmute_conversation": "Unmute conversation",
|
"status.unmute_conversation": "Unmute conversation",
|
||||||
"status.unpin": "Unpin from profile",
|
"status.unpin": "Unpin from profile",
|
||||||
"suggestions.dismiss": "Dismiss suggestion",
|
"suggestions.dismiss": "Dismiss suggestion",
|
||||||
|
@ -122,6 +122,8 @@
|
|||||||
"confirmations.mute.confirm": "ミュート",
|
"confirmations.mute.confirm": "ミュート",
|
||||||
"confirmations.mute.explanation": "これにより相手のトゥートと返信は見えなくなりますが、相手はあなたをフォローし続けトゥートを見ることができます。",
|
"confirmations.mute.explanation": "これにより相手のトゥートと返信は見えなくなりますが、相手はあなたをフォローし続けトゥートを見ることができます。",
|
||||||
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
|
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
|
||||||
|
"confirmations.quote.confirm": "引用",
|
||||||
|
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
|
||||||
"confirmations.redraft.confirm": "削除して下書きに戻す",
|
"confirmations.redraft.confirm": "削除して下書きに戻す",
|
||||||
"confirmations.redraft.message": "本当にこのトゥートを削除して下書きに戻しますか? このトゥートへのお気に入り登録やブーストは失われ、返信は孤立することになります。",
|
"confirmations.redraft.message": "本当にこのトゥートを削除して下書きに戻しますか? このトゥートへのお気に入り登録やブーストは失われ、返信は孤立することになります。",
|
||||||
"confirmations.reply.confirm": "返信",
|
"confirmations.reply.confirm": "返信",
|
||||||
@ -369,6 +371,7 @@
|
|||||||
"privacy.public.short": "公開",
|
"privacy.public.short": "公開",
|
||||||
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
|
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
|
||||||
"privacy.unlisted.short": "未収載",
|
"privacy.unlisted.short": "未収載",
|
||||||
|
"quote_indicator.cancel": "キャンセル",
|
||||||
"refresh": "更新",
|
"refresh": "更新",
|
||||||
"qr_modal.description": "QRコードを読み取って簡単にプロフィールにアクセスしましょう。",
|
"qr_modal.description": "QRコードを読み取って簡単にプロフィールにアクセスしましょう。",
|
||||||
"regeneration_indicator.label": "読み込み中…",
|
"regeneration_indicator.label": "読み込み中…",
|
||||||
@ -403,6 +406,7 @@
|
|||||||
"status.block": "@{name}さんをブロック",
|
"status.block": "@{name}さんをブロック",
|
||||||
"status.bookmark": "ブックマーク",
|
"status.bookmark": "ブックマーク",
|
||||||
"status.cancel_reblog_private": "ブースト解除",
|
"status.cancel_reblog_private": "ブースト解除",
|
||||||
|
"status.cannot_quote": "この投稿は引用できません",
|
||||||
"status.cannot_reblog": "この投稿はブーストできません",
|
"status.cannot_reblog": "この投稿はブーストできません",
|
||||||
"status.copy": "トゥートへのリンクをコピー",
|
"status.copy": "トゥートへのリンクをコピー",
|
||||||
"status.delete": "削除",
|
"status.delete": "削除",
|
||||||
@ -417,9 +421,11 @@
|
|||||||
"status.more": "もっと見る",
|
"status.more": "もっと見る",
|
||||||
"status.mute": "@{name}さんをミュート",
|
"status.mute": "@{name}さんをミュート",
|
||||||
"status.mute_conversation": "会話をミュート",
|
"status.mute_conversation": "会話をミュート",
|
||||||
|
"status.muted_quote": "ミュートされた引用",
|
||||||
"status.open": "詳細を表示",
|
"status.open": "詳細を表示",
|
||||||
"status.pin": "プロフィールに固定表示",
|
"status.pin": "プロフィールに固定表示",
|
||||||
"status.pinned": "固定されたトゥート",
|
"status.pinned": "固定されたトゥート",
|
||||||
|
"status.quote": "引用",
|
||||||
"status.read_more": "もっと見る",
|
"status.read_more": "もっと見る",
|
||||||
"status.reblog": "ブースト",
|
"status.reblog": "ブースト",
|
||||||
"status.reblog_private": "ブースト",
|
"status.reblog_private": "ブースト",
|
||||||
@ -436,8 +442,10 @@
|
|||||||
"status.show_less_all": "全て隠す",
|
"status.show_less_all": "全て隠す",
|
||||||
"status.show_more": "もっと見る",
|
"status.show_more": "もっと見る",
|
||||||
"status.show_more_all": "全て見る",
|
"status.show_more_all": "全て見る",
|
||||||
|
"status.show_poll": "アンケートを表示",
|
||||||
"status.show_thread": "スレッドを表示",
|
"status.show_thread": "スレッドを表示",
|
||||||
"status.uncached_media_warning": "利用できません",
|
"status.uncached_media_warning": "利用できません",
|
||||||
|
"status.unlisted_quote": "未収載の引用",
|
||||||
"status.unmute_conversation": "会話のミュートを解除",
|
"status.unmute_conversation": "会話のミュートを解除",
|
||||||
"status.unpin": "プロフィールへの固定を解除",
|
"status.unpin": "プロフィールへの固定を解除",
|
||||||
"suggestions.dismiss": "隠す",
|
"suggestions.dismiss": "隠す",
|
||||||
|
@ -5,6 +5,8 @@ import {
|
|||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_REPLY_CANCEL,
|
COMPOSE_REPLY_CANCEL,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
|
COMPOSE_QUOTE,
|
||||||
|
COMPOSE_QUOTE_CANCEL,
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
COMPOSE_SUBMIT_REQUEST,
|
COMPOSE_SUBMIT_REQUEST,
|
||||||
COMPOSE_SUBMIT_SUCCESS,
|
COMPOSE_SUBMIT_SUCCESS,
|
||||||
@ -59,6 +61,8 @@ const initialState = ImmutableMap({
|
|||||||
caretPosition: null,
|
caretPosition: null,
|
||||||
preselectDate: null,
|
preselectDate: null,
|
||||||
in_reply_to: null,
|
in_reply_to: null,
|
||||||
|
quote_from: null,
|
||||||
|
quote_from_url: null,
|
||||||
is_composing: false,
|
is_composing: false,
|
||||||
is_submitting: false,
|
is_submitting: false,
|
||||||
is_changing_upload: false,
|
is_changing_upload: false,
|
||||||
@ -102,6 +106,7 @@ function clearAll(state) {
|
|||||||
map.set('is_submitting', false);
|
map.set('is_submitting', false);
|
||||||
map.set('is_changing_upload', false);
|
map.set('is_changing_upload', false);
|
||||||
map.set('in_reply_to', null);
|
map.set('in_reply_to', null);
|
||||||
|
map.set('quote_from', null);
|
||||||
map.set('privacy', state.get('default_privacy'));
|
map.set('privacy', state.get('default_privacy'));
|
||||||
map.set('sensitive', false);
|
map.set('sensitive', false);
|
||||||
map.update('media_attachments', list => list.clear());
|
map.update('media_attachments', list => list.clear());
|
||||||
@ -247,6 +252,17 @@ const updateSuggestionTags = (state, token) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
export default function compose(state = initialState, action) {
|
export default function compose(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
@ -292,6 +308,8 @@ export default function compose(state = initialState, action) {
|
|||||||
case COMPOSE_REPLY:
|
case COMPOSE_REPLY:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('in_reply_to', action.status.get('id'));
|
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));
|
map.set('text', statusToTextMentions(state, action.status));
|
||||||
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
|
||||||
map.set('focusDate', new Date());
|
map.set('focusDate', new Date());
|
||||||
@ -299,6 +317,25 @@ export default function compose(state = initialState, action) {
|
|||||||
map.set('preselectDate', new Date());
|
map.set('preselectDate', new Date());
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
|
|
||||||
|
if (action.status.get('spoiler_text').length > 0) {
|
||||||
|
map.set('spoiler', true);
|
||||||
|
map.set('spoiler_text', action.status.get('spoiler_text'));
|
||||||
|
} else {
|
||||||
|
map.set('spoiler', false);
|
||||||
|
map.set('spoiler_text', '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
case COMPOSE_QUOTE:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
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('preselectDate', new Date());
|
||||||
|
map.set('idempotencyKey', uuid());
|
||||||
|
|
||||||
if (action.status.get('spoiler_text').length > 0) {
|
if (action.status.get('spoiler_text').length > 0) {
|
||||||
map.set('spoiler', true);
|
map.set('spoiler', true);
|
||||||
map.set('spoiler_text', action.status.get('spoiler_text'));
|
map.set('spoiler_text', action.status.get('spoiler_text'));
|
||||||
@ -308,9 +345,12 @@ export default function compose(state = initialState, action) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
case COMPOSE_REPLY_CANCEL:
|
case COMPOSE_REPLY_CANCEL:
|
||||||
|
case COMPOSE_QUOTE_CANCEL:
|
||||||
case COMPOSE_RESET:
|
case COMPOSE_RESET:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('in_reply_to', null);
|
map.set('in_reply_to', null);
|
||||||
|
map.set('quote_from', null);
|
||||||
|
map.set('quote_from_url', null);
|
||||||
map.set('text', '');
|
map.set('text', '');
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', false);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
@ -399,8 +439,10 @@ export default function compose(state = initialState, action) {
|
|||||||
}));
|
}));
|
||||||
case REDRAFT:
|
case REDRAFT:
|
||||||
return state.withMutations(map => {
|
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('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('privacy', action.status.get('visibility'));
|
||||||
map.set('media_attachments', action.status.get('media_attachments'));
|
map.set('media_attachments', action.status.get('media_attachments'));
|
||||||
map.set('focusDate', new Date());
|
map.set('focusDate', new Date());
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
|
COMPOSE_QUOTE,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ export default function search(state = initialState, action) {
|
|||||||
case COMPOSE_REPLY:
|
case COMPOSE_REPLY:
|
||||||
case COMPOSE_MENTION:
|
case COMPOSE_MENTION:
|
||||||
case COMPOSE_DIRECT:
|
case COMPOSE_DIRECT:
|
||||||
|
case COMPOSE_QUOTE:
|
||||||
return state.set('hidden', true);
|
return state.set('hidden', true);
|
||||||
case SEARCH_FETCH_SUCCESS:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
return state.set('results', ImmutableMap({
|
return state.set('results', ImmutableMap({
|
||||||
|
@ -13,6 +13,8 @@ import {
|
|||||||
STATUS_REVEAL,
|
STATUS_REVEAL,
|
||||||
STATUS_HIDE,
|
STATUS_HIDE,
|
||||||
STATUS_COLLAPSE,
|
STATUS_COLLAPSE,
|
||||||
|
QUOTE_REVEAL,
|
||||||
|
QUOTE_HIDE,
|
||||||
} from '../actions/statuses';
|
} from '../actions/statuses';
|
||||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||||
@ -75,6 +77,14 @@ export default function statuses(state = initialState, action) {
|
|||||||
});
|
});
|
||||||
case STATUS_COLLAPSE:
|
case STATUS_COLLAPSE:
|
||||||
return state.setIn([action.id, 'collapsed'], action.isCollapsed);
|
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:
|
case TIMELINE_DELETE:
|
||||||
return deleteStatus(state, action.id, action.references);
|
return deleteStatus(state, action.id, action.references);
|
||||||
default:
|
default:
|
||||||
|
@ -88,22 +88,60 @@ export const makeGetStatus = () => {
|
|||||||
[
|
[
|
||||||
(state, { id }) => state.getIn(['statuses', id]),
|
(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, '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', 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, '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(['relationships', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', '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'])]),
|
||||||
|
(state, { id }) => state.getIn(['accounts', state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'quote', 'account']), 'moved'])]),
|
||||||
getFiltersRegex,
|
getFiltersRegex,
|
||||||
],
|
],
|
||||||
|
|
||||||
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
|
(statusBase, statusReblog, statusQuote, accountBase, accountReblog, accountQuote, accountReblogQuote, relationship, reblogRelationship, quoteRelationship, reblogQuoteRelationship, moved, reblogMoved, quoteMoved, reblogQuoteMoved, filtersRegex) => {
|
||||||
if (!statusBase) {
|
if (!statusBase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountBase = accountBase.withMutations(map => {
|
||||||
|
map.set('relationship', relationship);
|
||||||
|
map.set('moved', moved);
|
||||||
|
});
|
||||||
|
|
||||||
if (statusReblog) {
|
if (statusReblog) {
|
||||||
|
accountReblog = accountReblog.withMutations(map => {
|
||||||
|
map.set('relationship', reblogRelationship);
|
||||||
|
map.set('moved', reblogMoved);
|
||||||
|
});
|
||||||
statusReblog = statusReblog.set('account', accountReblog);
|
statusReblog = statusReblog.set('account', accountReblog);
|
||||||
} else {
|
} else {
|
||||||
statusReblog = null;
|
statusReblog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (statusQuote) {
|
||||||
|
accountQuote = accountQuote.withMutations(map => {
|
||||||
|
map.set('relationship', quoteRelationship);
|
||||||
|
map.set('moved', quoteMoved);
|
||||||
|
});
|
||||||
|
statusQuote = statusQuote.set('account', accountQuote);
|
||||||
|
} else {
|
||||||
|
statusQuote = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusReblog && accountReblogQuote) {
|
||||||
|
accountReblogQuote = accountReblog.withMutations(map => {
|
||||||
|
map.set('relationship', reblogQuoteRelationship);
|
||||||
|
map.set('moved', reblogQuoteMoved);
|
||||||
|
});
|
||||||
|
statusReblog = statusReblog.setIn(['quote', 'account'], accountReblogQuote);
|
||||||
|
}
|
||||||
|
|
||||||
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
|
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
|
||||||
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
||||||
return null;
|
return null;
|
||||||
@ -114,6 +152,7 @@ export const makeGetStatus = () => {
|
|||||||
|
|
||||||
return statusBase.withMutations(map => {
|
return statusBase.withMutations(map => {
|
||||||
map.set('reblog', statusReblog);
|
map.set('reblog', statusReblog);
|
||||||
|
map.set('quote', statusQuote);
|
||||||
map.set('account', accountBase);
|
map.set('account', accountBase);
|
||||||
map.set('filtered', filtered);
|
map.set('filtered', filtered);
|
||||||
});
|
});
|
||||||
|
3
app/javascript/mastodon/utils/uniq.js
Normal file
3
app/javascript/mastodon/utils/uniq.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const uniq = array => {
|
||||||
|
return array.filter((x, i, self) => self.indexOf(x) === i)
|
||||||
|
};
|
@ -291,6 +291,31 @@ 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('.detailed-status__display-name')) {
|
||||||
|
url = target.closest('.detailed-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()
|
loadPolyfills()
|
||||||
|
@ -780,26 +780,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator {
|
.reply-indicator {
|
||||||
|
background: $ui-primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-indicator {
|
||||||
|
background: $success-green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator,
|
||||||
|
.quote-indicator {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background: $ui-primary-color;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
min-height: 23px;
|
min-height: 23px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 0 2 auto;
|
flex: 0 2 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator__header {
|
.reply-indicator__header,
|
||||||
|
.quote-indicator__header {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator__cancel {
|
.reply-indicator__cancel,
|
||||||
|
.quote-indicator__cancel {
|
||||||
float: right;
|
float: right;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator__display-name {
|
.reply-indicator__display-name,
|
||||||
|
.quote-indicator__display-name {
|
||||||
color: $inverted-text-color;
|
color: $inverted-text-color;
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -809,7 +820,8 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator__display-avatar {
|
.reply-indicator__display-avatar,
|
||||||
|
.quote-indicator__display-avatar {
|
||||||
float: left;
|
float: left;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
@ -823,7 +835,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status__content,
|
.status__content,
|
||||||
.reply-indicator__content {
|
.reply-indicator__content,
|
||||||
|
.quote-indicator__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
@ -1015,6 +1028,76 @@
|
|||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-inline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-status {
|
||||||
|
border: solid 1px $ui-base-lighter-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& > .unlisted-quote {
|
||||||
|
color: $dark-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
& > button {
|
||||||
|
color: $dark-text-color;
|
||||||
|
font-size: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .muted-quote {
|
||||||
|
color: $dark-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status__avatar,
|
||||||
|
.detailed-status__display-avatar {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
position: absolute;
|
||||||
|
top: 5px !important;
|
||||||
|
left: 5px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name__account {
|
||||||
|
color: $ui-base-lighter-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailed-status__display-name {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted .quote-status .display-name {
|
||||||
|
color: $ui-base-lighter-color;
|
||||||
|
}
|
||||||
|
|
||||||
.status__prepend-icon-wrapper {
|
.status__prepend-icon-wrapper {
|
||||||
left: -26px;
|
left: -26px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -1273,7 +1356,8 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reply-indicator__content {
|
.reply-indicator__content,
|
||||||
|
.quote-indicator__content {
|
||||||
color: $inverted-text-color;
|
color: $inverted-text-color;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
@ -1472,77 +1556,6 @@ a .account__avatar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.account__action-bar {
|
|
||||||
border-top: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
line-height: 36px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account__action-bar-dropdown {
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
.icon-button {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown--active {
|
|
||||||
.dropdown__content.dropdown__right {
|
|
||||||
left: 6px;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
bottom: initial;
|
|
||||||
margin-left: 11px;
|
|
||||||
margin-top: -7px;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.account__action-bar-links {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
line-height: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account__action-bar__tab {
|
|
||||||
text-decoration: none;
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 0 1 100%;
|
|
||||||
border-right: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 4px solid transparent;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-bottom: 4px solid $ui-highlight-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > span {
|
|
||||||
display: block;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 11px;
|
|
||||||
color: $darker-text-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: $primary-text-color;
|
|
||||||
|
|
||||||
@each $lang in $cjk-langs {
|
|
||||||
&:lang(#{$lang}) {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-authorize {
|
.account-authorize {
|
||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
|
|
||||||
@ -1629,6 +1642,15 @@ a.account__display-name {
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account__action-bar {
|
||||||
|
position: absolute;
|
||||||
|
height: 24px;
|
||||||
|
width: 48px;
|
||||||
|
top: 60px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.status__avatar {
|
.status__avatar {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
@ -2526,6 +2548,11 @@ a.account__display-name {
|
|||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account__action-bar {
|
||||||
|
top: 67px;
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2661,13 +2688,20 @@ a.account__display-name {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.follow-recommendations-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.column-actions {
|
.column-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
padding-bottom: 200px;
|
padding-bottom: 200px;
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&__background {
|
&__background {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -5440,9 +5474,10 @@ a.status-card.compact:hover {
|
|||||||
.report-modal__target {
|
.report-modal__target {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
|
|
||||||
.media-modal__close {
|
.report-modal__close {
|
||||||
top: 14px;
|
position: absolute;
|
||||||
right: 15px;
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,6 +63,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.quote-status {
|
||||||
|
border: solid 1px $ui-base-lighter-color;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.status__avatar {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 740px) {
|
@media screen and (max-width: 740px) {
|
||||||
.detailed-status,
|
.detailed-status,
|
||||||
.status,
|
.status,
|
||||||
@ -77,6 +99,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standalone-timeline .quote-status {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.button.logo-button {
|
.button.logo-button {
|
||||||
flex: 0 auto;
|
flex: 0 auto;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
@ -46,7 +46,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
|
return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
|
||||||
|
|
||||||
lock_or_fail("create:#{object_uri}") do
|
lock_or_fail("create:#{object_uri}") do
|
||||||
return if delete_arrived_first?(object_uri) || poll_vote? # rubocop:disable Lint/NonLocalExitFromIterator
|
return if delete_arrived_first?(object_uri) || poll_vote?
|
||||||
|
|
||||||
@status = find_existing_status
|
@status = find_existing_status
|
||||||
|
|
||||||
@ -73,6 +73,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
@mentions = []
|
@mentions = []
|
||||||
@params = {}
|
@params = {}
|
||||||
|
|
||||||
|
process_quote
|
||||||
process_status_params
|
process_status_params
|
||||||
process_tags
|
process_tags
|
||||||
process_audience
|
process_audience
|
||||||
@ -112,6 +113,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
conversation: conversation_from_uri(@object['conversation']),
|
conversation: conversation_from_uri(@object['conversation']),
|
||||||
media_attachment_ids: process_attachments.take(4).map(&:id),
|
media_attachment_ids: process_attachments.take(4).map(&:id),
|
||||||
poll: process_poll,
|
poll: process_poll,
|
||||||
|
quote: quote,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -382,7 +384,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
def text_from_content
|
def text_from_content
|
||||||
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
|
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
|
||||||
|
|
||||||
if @object['content'].present?
|
if @object['quoteUrl'].blank? && @object['_misskey_quote'].present?
|
||||||
|
Formatter.instance.linkify(@object['_misskey_content'])
|
||||||
|
elsif @object['content'].present?
|
||||||
@object['content']
|
@object['content']
|
||||||
elsif content_language_map?
|
elsif content_language_map?
|
||||||
@object['contentMap'].values.first
|
@object['contentMap'].values.first
|
||||||
@ -500,4 +504,24 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
poll.reload
|
poll.reload
|
||||||
retry
|
retry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def quote
|
||||||
|
@quote ||= quote_from_url(@object['quoteUrl'] || @object['_misskey_quote'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_quote
|
||||||
|
if quote.nil? && md = @object['content']&.match(/QT:\s*\[<a href=\"([^\"]+).*?\]/)
|
||||||
|
@quote = quote_from_url(md[1])
|
||||||
|
@object['content'] = @object['content'].sub(/QT:\s*\[.*?\]/, '<span class="quote-inline"><br/>\1</span>')
|
||||||
|
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
|
end
|
||||||
|
@ -24,6 +24,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
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' },
|
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' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
|
quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def self.default_key_transform
|
def self.default_key_transform
|
||||||
|
@ -14,6 +14,8 @@ module ActivityPub::CaseTransform
|
|||||||
when String
|
when String
|
||||||
camel_lower_cache[value] ||= if value.start_with?('_:')
|
camel_lower_cache[value] ||= if value.start_with?('_:')
|
||||||
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
|
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
|
||||||
|
elsif value.start_with?('_')
|
||||||
|
value
|
||||||
else
|
else
|
||||||
value.underscore.camelize(:lower)
|
value.underscore.camelize(:lower)
|
||||||
end
|
end
|
||||||
|
@ -43,6 +43,7 @@ class Formatter
|
|||||||
html = encode_and_link_urls(html, linkable_accounts)
|
html = encode_and_link_urls(html, linkable_accounts)
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||||
html = simple_format(html, {}, sanitize: false)
|
html = simple_format(html, {}, sanitize: false)
|
||||||
|
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
|
||||||
html = html.delete("\n")
|
html = html.delete("\n")
|
||||||
|
|
||||||
mdLinkDecoder = MDLinkDecoder.new(html)
|
mdLinkDecoder = MDLinkDecoder.new(html)
|
||||||
@ -53,6 +54,17 @@ class Formatter
|
|||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
end
|
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)
|
def reformat(html)
|
||||||
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
sanitize(html, Sanitize::Config::MASTODON_STRICT)
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
@ -206,6 +218,12 @@ class Formatter
|
|||||||
end
|
end
|
||||||
# rubocop:enable Metrics/BlockNesting
|
# 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/>QT: #{link}</span>\\1")
|
||||||
|
end
|
||||||
|
|
||||||
def rewrite(text, entities)
|
def rewrite(text, entities)
|
||||||
text = text.to_s
|
text = text.to_s
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ module AccountAvatar
|
|||||||
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||||
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
|
||||||
validates_attachment_size :avatar, less_than: LIMIT
|
validates_attachment_size :avatar, less_than: LIMIT
|
||||||
remotable_attachment :avatar, LIMIT
|
remotable_attachment :avatar, LIMIT, suppress_errors: false
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_original_url
|
def avatar_original_url
|
||||||
|
@ -22,7 +22,7 @@ module AccountHeader
|
|||||||
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
|
||||||
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
|
||||||
validates_attachment_size :header, less_than: LIMIT
|
validates_attachment_size :header, less_than: LIMIT
|
||||||
remotable_attachment :header, LIMIT
|
remotable_attachment :header, LIMIT, suppress_errors: false
|
||||||
end
|
end
|
||||||
|
|
||||||
def header_original_url
|
def header_original_url
|
||||||
|
@ -28,9 +28,11 @@ module Remotable
|
|||||||
end
|
end
|
||||||
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
|
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
|
||||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||||
|
public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present?
|
||||||
raise e unless suppress_errors
|
raise e unless suppress_errors
|
||||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Paperclip::Error, Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e
|
rescue Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Paperclip::Error, Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e
|
||||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||||
|
public_send("#{attachment_name}=", nil) if public_send("#{attachment_name}_file_name").present?
|
||||||
end
|
end
|
||||||
|
|
||||||
nil
|
nil
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
# in_reply_to_account_id :bigint(8)
|
# in_reply_to_account_id :bigint(8)
|
||||||
# poll_id :bigint(8)
|
# poll_id :bigint(8)
|
||||||
# deleted_at :datetime
|
# deleted_at :datetime
|
||||||
|
# quote_id :bigint(8)
|
||||||
#
|
#
|
||||||
|
|
||||||
class Status < ApplicationRecord
|
class Status < ApplicationRecord
|
||||||
@ -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 :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 :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 :favourites, inverse_of: :status, dependent: :destroy
|
||||||
has_many :bookmarks, 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 :mentions, dependent: :destroy, inverse_of: :status
|
||||||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||||
has_many :media_attachments, dependent: :nullify
|
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 :tags
|
||||||
has_and_belongs_to_many :preview_cards
|
has_and_belongs_to_many :preview_cards
|
||||||
@ -77,6 +80,7 @@ class Status < ApplicationRecord
|
|||||||
validates_with DisallowedHashtagsValidator
|
validates_with DisallowedHashtagsValidator
|
||||||
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
validates :reblog, uniqueness: { scope: :account }, if: :reblog?
|
||||||
validates :visibility, exclusion: { in: %w(direct limited) }, 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
|
accepts_nested_attributes_for :poll
|
||||||
|
|
||||||
@ -169,6 +173,14 @@ class Status < ApplicationRecord
|
|||||||
!reblog_of_id.nil?
|
!reblog_of_id.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def quote?
|
||||||
|
!quote_id.nil? && quote
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote_visibility
|
||||||
|
quote&.visibility
|
||||||
|
end
|
||||||
|
|
||||||
def within_realtime_window?
|
def within_realtime_window?
|
||||||
created_at >= REAL_TIME_WINDOW.ago
|
created_at >= REAL_TIME_WINDOW.ago
|
||||||
end
|
end
|
||||||
@ -229,7 +241,7 @@ class Status < ApplicationRecord
|
|||||||
fields = [spoiler_text, text]
|
fields = [spoiler_text, text]
|
||||||
fields += preloadable_poll.options unless preloadable_poll.nil?
|
fields += preloadable_poll.options unless preloadable_poll.nil?
|
||||||
|
|
||||||
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
|
@emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def replies_count
|
def replies_count
|
||||||
|
@ -177,7 +177,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def href
|
def href
|
||||||
explore_hashtag_url(object)
|
tag_url(object)
|
||||||
end
|
end
|
||||||
|
|
||||||
def name
|
def name
|
||||||
|
@ -9,6 +9,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||||||
:atom_uri, :in_reply_to_atom_uri,
|
:atom_uri, :in_reply_to_atom_uri,
|
||||||
:conversation
|
: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
|
||||||
attribute :content_map, if: :language?
|
attribute :content_map, if: :language?
|
||||||
|
|
||||||
@ -125,6 +128,16 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||||||
end
|
end
|
||||||
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?
|
def local?
|
||||||
object.account.local?
|
object.account.local?
|
||||||
end
|
end
|
||||||
|
@ -5,7 +5,8 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||||||
|
|
||||||
attributes :uri, :title, :short_description, :description, :email,
|
attributes :uri, :title, :short_description, :description, :email,
|
||||||
:version, :urls, :stats, :thumbnail,
|
: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
|
has_one :contact_account, serializer: REST::AccountSerializer
|
||||||
|
|
||||||
@ -69,6 +70,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||||||
Setting.min_invite_role == 'user'
|
Setting.min_invite_role == 'user'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def feature_quote
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def instance_presenter
|
def instance_presenter
|
||||||
|
@ -15,6 +15,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||||||
attribute :content, unless: :source_requested?
|
attribute :content, unless: :source_requested?
|
||||||
attribute :text, if: :source_requested?
|
attribute :text, if: :source_requested?
|
||||||
|
|
||||||
|
attribute :quote_id, if: -> { object.quote? }
|
||||||
|
|
||||||
belongs_to :reblog, serializer: REST::StatusSerializer
|
belongs_to :reblog, serializer: REST::StatusSerializer
|
||||||
belongs_to :application, if: :show_application?
|
belongs_to :application, if: :show_application?
|
||||||
belongs_to :account, serializer: REST::AccountSerializer
|
belongs_to :account, serializer: REST::AccountSerializer
|
||||||
@ -39,6 +41,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||||||
object.in_reply_to_account_id&.to_s
|
object.in_reply_to_account_id&.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def quote_id
|
||||||
|
object.quote_id.to_s
|
||||||
|
end
|
||||||
|
|
||||||
def current_user?
|
def current_user?
|
||||||
!current_user.nil?
|
!current_user.nil?
|
||||||
end
|
end
|
||||||
@ -167,3 +173,23 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||||||
end
|
end
|
||||||
end
|
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] ? true : false || 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] || false
|
||||||
|
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
|
||||||
|
@ -106,8 +106,16 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_fetchable_attributes!
|
def set_fetchable_attributes!
|
||||||
|
begin
|
||||||
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
|
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
|
||||||
|
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||||
|
RedownloadAvatarWorker.perform_in(rand(30..600).seconds, @account.id)
|
||||||
|
end
|
||||||
|
begin
|
||||||
@account.header_remote_url = image_url('image') || '' unless skip_download?
|
@account.header_remote_url = image_url('image') || '' unless skip_download?
|
||||||
|
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||||
|
RedownloadHeaderWorker.perform_in(rand(30..600).seconds, @account.id)
|
||||||
|
end
|
||||||
@account.statuses_count = outbox_total_items if outbox_total_items.present?
|
@account.statuses_count = outbox_total_items if outbox_total_items.present?
|
||||||
@account.following_count = following_total_items if following_total_items.present?
|
@account.following_count = following_total_items if following_total_items.present?
|
||||||
@account.followers_count = followers_total_items if followers_total_items.present?
|
@account.followers_count = followers_total_items if followers_total_items.present?
|
||||||
|
@ -67,7 +67,7 @@ class FetchLinkCardService < BaseService
|
|||||||
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
|
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
|
||||||
else
|
else
|
||||||
html = Nokogiri::HTML(@status.text)
|
html = Nokogiri::HTML(@status.text)
|
||||||
links = html.css('a')
|
links = html.css(':not(.quote-inline) > a')
|
||||||
urls = links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
|
urls = links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -26,11 +26,13 @@ class PostStatusService < BaseService
|
|||||||
@options = options
|
@options = options
|
||||||
@text = @options[:text] || ''
|
@text = @options[:text] || ''
|
||||||
@in_reply_to = @options[:thread]
|
@in_reply_to = @options[:thread]
|
||||||
|
@quote_id = @options[:quote_id]
|
||||||
|
|
||||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||||
|
|
||||||
validate_media!
|
validate_media!
|
||||||
preprocess_attributes!
|
preprocess_attributes!
|
||||||
|
preprocess_quote!
|
||||||
|
|
||||||
if scheduled?
|
if scheduled?
|
||||||
schedule_status!
|
schedule_status!
|
||||||
@ -47,6 +49,19 @@ class PostStatusService < BaseService
|
|||||||
|
|
||||||
private
|
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!
|
def preprocess_attributes!
|
||||||
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
|
@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?
|
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
|
||||||
@ -54,10 +69,21 @@ class PostStatusService < BaseService
|
|||||||
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
|
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
|
||||||
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
@scheduled_at = @options[:scheduled_at]&.to_datetime
|
||||||
@scheduled_at = nil if scheduled_in_the_past?
|
@scheduled_at = nil if scheduled_in_the_past?
|
||||||
|
if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
|
||||||
|
@quote_id = quote_from_url(md[1])&.id
|
||||||
|
@text.sub!(/QT:\s*\[.*?\]/, '')
|
||||||
|
end
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
raise ActiveRecord::RecordInvalid
|
raise ActiveRecord::RecordInvalid
|
||||||
end
|
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!
|
def process_status!
|
||||||
# The following transaction block is needed to wrap the UPDATEs to
|
# The following transaction block is needed to wrap the UPDATEs to
|
||||||
# the media attachments when the status is created
|
# the media attachments when the status is created
|
||||||
@ -164,6 +190,7 @@ class PostStatusService < BaseService
|
|||||||
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
|
language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
|
||||||
application: @options[:application],
|
application: @options[:application],
|
||||||
rate_limit: @options[:with_rate_limit],
|
rate_limit: @options[:with_rate_limit],
|
||||||
|
quote_id: @quote_id,
|
||||||
}.compact
|
}.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -25,6 +25,9 @@
|
|||||||
- if status.preloadable_poll
|
- if status.preloadable_poll
|
||||||
= render_poll_component(status)
|
= render_poll_component(status)
|
||||||
|
|
||||||
|
- if status.quote?
|
||||||
|
= render partial: "quote_status", locals: {status: status.quote}
|
||||||
|
|
||||||
- if !status.media_attachments.empty?
|
- if !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
= render_video_component(status, width: 670, height: 380, detailed: true)
|
= render_video_component(status, width: 670, height: 380, detailed: true)
|
||||||
|
35
app/views/statuses/_quote_status.html.haml
Normal file
35
app/views/statuses/_quote_status.html.haml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.status.quote-status{ dataurl: ActivityPub::TagManager.instance.url_for(status) }
|
||||||
|
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener' do
|
||||||
|
.status__avatar
|
||||||
|
%div
|
||||||
|
= image_tag status.account.avatar_static_url, width: 18, height: 18, alt: '', class: 'u-photo account__avatar'
|
||||||
|
%span.display-name
|
||||||
|
%bdi
|
||||||
|
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true)
|
||||||
|
|
||||||
|
%span.display-name__account
|
||||||
|
= acct(status.account)
|
||||||
|
= fa_icon('lock') if status.account.locked?
|
||||||
|
|
||||||
|
.status__content.emojify<
|
||||||
|
- if status.spoiler_text?
|
||||||
|
%p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }<
|
||||||
|
%span.p-summary> #{Formatter.instance.format_spoiler(status)}
|
||||||
|
%button.status__content__spoiler-link= t('statuses.show_more')
|
||||||
|
.e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}" }
|
||||||
|
= Formatter.instance.format_in_quote(status, custom_emojify: true)
|
||||||
|
|
||||||
|
- if !status.media_attachments.empty?
|
||||||
|
- if status.media_attachments.first.video?
|
||||||
|
- video = status.media_attachments.first
|
||||||
|
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json, quote: true 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: audio.file.url(:original), height: 60, 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: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true do
|
||||||
|
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
|
- elsif status.preview_card
|
||||||
|
= react_component :card, maxDescription: 10, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json, quote: true
|
@ -34,6 +34,9 @@
|
|||||||
- if status.preloadable_poll
|
- if status.preloadable_poll
|
||||||
= render_poll_component(status)
|
= render_poll_component(status)
|
||||||
|
|
||||||
|
- if status.quote?
|
||||||
|
= render partial: "statuses/quote_status", locals: {status: status.quote}
|
||||||
|
|
||||||
- if !status.media_attachments.empty?
|
- if !status.media_attachments.empty?
|
||||||
- if status.media_attachments.first.video?
|
- if status.media_attachments.first.video?
|
||||||
= render_video_component(status, width: 610, height: 343)
|
= render_video_component(status, width: 610, height: 343)
|
||||||
|
29
app/workers/redownload_avatar_worker.rb
Normal file
29
app/workers/redownload_avatar_worker.rb
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RedownloadAvatarWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
include ExponentialBackoff
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull', retry: 7
|
||||||
|
|
||||||
|
def perform(id)
|
||||||
|
account = Account.find(id)
|
||||||
|
|
||||||
|
return if account.suspended? || DomainBlock.rule_for(account.domain)&.reject_media?
|
||||||
|
return if account.avatar_remote_url.blank? || account.avatar_file_name.present?
|
||||||
|
|
||||||
|
account.reset_avatar!
|
||||||
|
account.save!
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
# Do nothing
|
||||||
|
rescue Mastodon::UnexpectedResponseError => e
|
||||||
|
response = e.response
|
||||||
|
|
||||||
|
if response_error_unsalvageable?(response)
|
||||||
|
# Give up
|
||||||
|
else
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
29
app/workers/redownload_header_worker.rb
Normal file
29
app/workers/redownload_header_worker.rb
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RedownloadHeaderWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
include ExponentialBackoff
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
sidekiq_options queue: 'pull', retry: 7
|
||||||
|
|
||||||
|
def perform(id)
|
||||||
|
account = Account.find(id)
|
||||||
|
|
||||||
|
return if account.suspended? || DomainBlock.rule_for(account.domain)&.reject_media?
|
||||||
|
return if account.header_remote_url.blank? || account.header_file_name.present?
|
||||||
|
|
||||||
|
account.reset_header!
|
||||||
|
account.save!
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
# Do nothing
|
||||||
|
rescue Mastodon::UnexpectedResponseError => e
|
||||||
|
response = e.response
|
||||||
|
|
||||||
|
if response_error_unsalvageable?(response)
|
||||||
|
# Give up
|
||||||
|
else
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -7,7 +7,7 @@
|
|||||||
"check_name": "SQL",
|
"check_name": "SQL",
|
||||||
"message": "Possible SQL injection",
|
"message": "Possible SQL injection",
|
||||||
"file": "app/models/report.rb",
|
"file": "app/models/report.rb",
|
||||||
"line": 112,
|
"line": 113,
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
"code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")",
|
"code": "Admin::ActionLog.from(\"(#{[Admin::ActionLog.where(:target_type => \"Report\", :target_id => id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Account\", :target_id => target_account_id, :created_at => ((created_at..updated_at))).unscope(:order), Admin::ActionLog.where(:target_type => \"Status\", :target_id => status_ids, :created_at => ((created_at..updated_at))).unscope(:order)].map do\n \"(#{query.to_sql})\"\n end.join(\" UNION ALL \")}) AS admin_action_logs\")",
|
||||||
"render_path": null,
|
"render_path": null,
|
||||||
@ -67,7 +67,7 @@
|
|||||||
"check_name": "SQL",
|
"check_name": "SQL",
|
||||||
"message": "Possible SQL injection",
|
"message": "Possible SQL injection",
|
||||||
"file": "app/models/account.rb",
|
"file": "app/models/account.rb",
|
||||||
"line": 491,
|
"line": 479,
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
"code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])",
|
"code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])",
|
||||||
"render_path": null,
|
"render_path": null,
|
||||||
@ -120,6 +120,26 @@
|
|||||||
"confidence": "High",
|
"confidence": "High",
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"warning_type": "Mass Assignment",
|
||||||
|
"warning_code": 105,
|
||||||
|
"fingerprint": "874be88fedf4c680926845e9a588d3197765a6ccbfdd76466b44cc00151c612e",
|
||||||
|
"check_name": "PermitAttributes",
|
||||||
|
"message": "Potentially dangerous key allowed for mass assignment",
|
||||||
|
"file": "app/controllers/api/v1/admin/reports_controller.rb",
|
||||||
|
"line": 78,
|
||||||
|
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
|
||||||
|
"code": "params.permit(:resolved, :account_id, :target_account_id)",
|
||||||
|
"render_path": null,
|
||||||
|
"location": {
|
||||||
|
"type": "method",
|
||||||
|
"class": "Api::V1::Admin::ReportsController",
|
||||||
|
"method": "filter_params"
|
||||||
|
},
|
||||||
|
"user_input": ":account_id",
|
||||||
|
"confidence": "High",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"warning_type": "SQL Injection",
|
"warning_type": "SQL Injection",
|
||||||
"warning_code": 0,
|
"warning_code": 0,
|
||||||
@ -127,7 +147,7 @@
|
|||||||
"check_name": "SQL",
|
"check_name": "SQL",
|
||||||
"message": "Possible SQL injection",
|
"message": "Possible SQL injection",
|
||||||
"file": "app/models/account.rb",
|
"file": "app/models/account.rb",
|
||||||
"line": 460,
|
"line": 448,
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])",
|
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])",
|
||||||
"render_path": null,
|
"render_path": null,
|
||||||
@ -207,7 +227,7 @@
|
|||||||
"check_name": "SQL",
|
"check_name": "SQL",
|
||||||
"message": "Possible SQL injection",
|
"message": "Possible SQL injection",
|
||||||
"file": "app/models/account.rb",
|
"file": "app/models/account.rb",
|
||||||
"line": 507,
|
"line": 495,
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])",
|
"code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])",
|
||||||
"render_path": null,
|
"render_path": null,
|
||||||
@ -241,6 +261,6 @@
|
|||||||
"note": ""
|
"note": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated": "2020-12-07 01:17:13 +0100",
|
"updated": "2021-05-11 20:22:27 +0900",
|
||||||
"brakeman_version": "4.10.0"
|
"brakeman_version": "5.0.1"
|
||||||
}
|
}
|
||||||
|
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
|
@ -815,11 +815,13 @@ ActiveRecord::Schema.define(version: 2021_05_07_001928) do
|
|||||||
t.bigint "in_reply_to_account_id"
|
t.bigint "in_reply_to_account_id"
|
||||||
t.bigint "poll_id"
|
t.bigint "poll_id"
|
||||||
t.datetime "deleted_at"
|
t.datetime "deleted_at"
|
||||||
|
t.bigint "quote_id"
|
||||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||||
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (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 ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (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 ["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 ["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_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 ["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 ["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
|
t.index ["uri"], name: "index_statuses_on_uri", unique: true
|
||||||
end
|
end
|
||||||
|
@ -108,9 +108,11 @@ module Paperclip
|
|||||||
|
|
||||||
final_file = Paperclip::Transcoder.make(file, options, attachment)
|
final_file = Paperclip::Transcoder.make(file, options, attachment)
|
||||||
|
|
||||||
|
if options[:style] == :original
|
||||||
attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.mp4'
|
attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.mp4'
|
||||||
attachment.instance.file_content_type = 'video/mp4'
|
attachment.instance.file_content_type = 'video/mp4'
|
||||||
attachment.instance.type = MediaAttachment.types[:gifv]
|
attachment.instance.type = MediaAttachment.types[:gifv]
|
||||||
|
end
|
||||||
|
|
||||||
final_file
|
final_file
|
||||||
end
|
end
|
||||||
@ -118,7 +120,7 @@ module Paperclip
|
|||||||
private
|
private
|
||||||
|
|
||||||
def needs_convert?
|
def needs_convert?
|
||||||
options[:style] == :original && GifReader.animated?(file.path)
|
GifReader.animated?(file.path)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -31,6 +31,7 @@ class Sanitize
|
|||||||
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
|
next true if /^(h|p|u|dt|e)-/.match?(e) # microformats classes
|
||||||
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
|
next true if /^(mention|hashtag)$/.match?(e) # semantic classes
|
||||||
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
|
next true if /^(ellipsis|invisible)$/.match?(e) # link formatting classes
|
||||||
|
next true if /^quote-inline$/.match?(e) # quote inline classes
|
||||||
end
|
end
|
||||||
|
|
||||||
node['class'] = class_list.join(' ')
|
node['class'] = class_list.join(' ')
|
||||||
|
Loading…
Reference in New Issue
Block a user