Add status reference

This commit is contained in:
noellabo 2022-03-17 23:25:16 +09:00
parent e62da34738
commit 999e361892
118 changed files with 3316 additions and 324 deletions

View file

@ -64,6 +64,11 @@ class StatusesIndex < Chewy::Index
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
crutch :status_references do |collection|
data = ::StatusReference.joins(:status).where(target_status_id: collection.map(&:id)).where(status: { account: Account.local }).pluck(:target_status_id, :'status.account_id')
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long'

View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
class ActivityPub::ReferencesController < ActivityPub::BaseController
include SignatureVerification
include Authorization
include AccountOwnedConcern
include StatusControllerConcern
before_action :require_signature!, if: :authorized_fetch_mode?
before_action :set_status
before_action :set_cache_headers
before_action :set_references
def index
expires_in 0, public: public_fetch_mode?
render json: references_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true
end
private
def pundit_user
signed_request_account
end
def set_status
@status = @account.statuses.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def load_statuses
cached_references
end
def cached_references
cache_collection(Status.where(id: results).reorder(:id), Status)
end
def results
@_results ||= begin
references = @status.reference_relationships.order(target_status_id: :asc)
references = references.where('target_status_id > ?', page_params[:min_id]) if page_params[:min_id].present?
references = references.limit(limit_param(REFERENCES_LIMIT))
references.pluck(:target_status_id)
end
end
def pagination_min_id
results.last
end
def records_continue?
results.size == limit_param(REFERENCES_LIMIT)
end
def references_collection_presenter
page = ActivityPub::CollectionPresenter.new(
id: ActivityPub::TagManager.instance.references_uri_for(@status, page_params),
type: :unordered,
part_of: ActivityPub::TagManager.instance.references_uri_for(@status),
items: load_statuses.map(&:uri),
next: next_page,
)
return page if page_requested?
ActivityPub::CollectionPresenter.new(
type: :unordered,
id: ActivityPub::TagManager.instance.references_uri_for(@status),
first: page,
)
end
def page_requested?
truthy_param?(:page)
end
def next_page
if records_continue?
ActivityPub::TagManager.instance.references_uri_for(@status, page_params.merge(min_id: pagination_min_id))
end
end
def page_params
params_slice(:min_id, :limit).merge(page: true)
end
end

View file

@ -76,6 +76,8 @@ class Api::V1::NotificationsController < Api::BaseController
val = params.permit(exclude_types: [])[:exclude_types] || []
val = [val] unless val.is_a?(Enumerable)
val = val << 'emoji_reaction' << 'status' unless new_notification_type_compatible?
val = val << 'emoji_reaction' unless current_user&.setting_enable_reaction
val = val << 'status_reference' unless current_user&.setting_enable_status_reference
val.uniq
end

View file

@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params
return {} if params[:data].blank?
params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction])
params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction, :status_reference])
end
end

View file

@ -0,0 +1,71 @@
# frozen_string_literal: true
class Api::V1::Statuses::ReferredByStatusesController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :set_status
after_action :insert_pagination_headers
def index
@statuses = load_statuses
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
private
def load_statuses
cached_referred_by_statuses
end
def cached_referred_by_statuses
cache_collection(Status.where(id: results.pluck(:id)), Status)
end
def results
@_results ||= Status.where(id: referred_by_statuses).to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def referred_by_statuses
@status.referred_by_statuses(current_user&.account)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_status_referred_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_referred_by_index_url pagination_params(min_id: pagination_since_id) unless results.empty?
end
def pagination_max_id
results.last.id
end
def pagination_since_id
results.first.id
end
def records_continue?
results.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def set_status
@status = Status.include_expired.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View file

@ -6,6 +6,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy]
before_action :require_user!, except: [:show, :context]
before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create]
before_action :set_circle, only: [:create]
@ -20,6 +21,11 @@ class Api::V1::StatusesController < Api::BaseController
# than this anyway
CONTEXT_LIMIT = 4_096
def index
@statuses = cache_collection(@statuses, Status)
render json: @statuses, each_serializer: REST::StatusSerializer
end
def show
@status = cache_collection([@status], Status).first
render json: @status, serializer: REST::StatusSerializer
@ -28,11 +34,19 @@ class Api::V1::StatusesController < Api::BaseController
def context
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(CONTEXT_LIMIT, current_account)
descendants_results = @status.descendants(CONTEXT_LIMIT, current_account)
references_results = @status.thread_references(CONTEXT_LIMIT, current_account)
unless ActiveModel::Type::Boolean.new.cast(status_params[:with_reference])
ancestors_results = (ancestors_results + references_results).sort_by {|status| status.id }
references_results = []
end
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
loaded_references = cache_collection(references_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references )
statuses = [@status] + @context.ancestors + @context.descendants + @context.references
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), account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id)
@ -54,13 +68,17 @@ class Api::V1::StatusesController < Api::BaseController
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'],
with_rate_limit: true,
quote_id: status_params[:quote_id].presence)
quote_id: status_params[:quote_id].presence,
status_reference_ids: (Array(status_params[:status_reference_ids]).uniq.map(&:to_i)),
status_reference_urls: status_params[:status_reference_urls] || []
)
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end
def destroy
@status = Status.include_expired.where(account_id: current_account.id).find(params[:id])
@status = Status.include_expired.where(account_id: current_account.id).find(status_params[:id])
authorize @status, :destroy?
@status.discard
@ -72,8 +90,12 @@ class Api::V1::StatusesController < Api::BaseController
private
def set_statuses
@statuses = Status.permitted_statuses_from_ids(status_ids, current_account)
end
def set_status
@status = Status.include_expired.find(params[:id])
@status = Status.include_expired.find(status_params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
@ -108,8 +130,17 @@ class Api::V1::StatusesController < Api::BaseController
@expires_at = status_params[:expires_at] || (status_params[:expires_in].blank? ? nil : (@scheduled_at || Time.now.utc) + status_params[:expires_in].to_i.seconds)
end
def status_ids
Array(statuses_params[:ids]).uniq.map(&:to_i)
end
def statuses_params
params.permit(ids: [])
end
def status_params
params.permit(
:id,
:status,
:in_reply_to_id,
:circle_id,
@ -122,13 +153,16 @@ class Api::V1::StatusesController < Api::BaseController
:expires_in,
:expires_at,
:expires_action,
:with_reference,
media_ids: [],
poll: [
:multiple,
:hide_totals,
:expires_in,
options: [],
]
],
status_reference_ids: [],
status_reference_urls: []
)
end

View file

@ -6,6 +6,7 @@ module StatusControllerConcern
ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20
REFERENCES_LIMIT = 60
def create_descendant_thread(starting_depth, statuses)
depth = starting_depth + statuses.size
@ -26,8 +27,61 @@ module StatusControllerConcern
end
end
def limit_param(default_limit)
return default_limit unless params[:limit]
[params[:limit].to_i.abs, default_limit * 2].min
end
def set_references
limit = limit_param(REFERENCES_LIMIT)
max_id = params[:max_id]&.to_i
min_id = params[:min_id]&.to_i
@references = references = cache_collection(
@status.thread_references(
DESCENDANTS_LIMIT,
current_account,
params[:max_descendant_thread_id]&.to_i,
params[:since_descendant_thread_id]&.to_i,
DESCENDANTS_DEPTH_LIMIT
),
Status
)
.sort_by {|status| status.id}.reverse
return if references.empty?
@references = begin
if min_id
references.drop_while {|status| status.id >= min_id }.take(limit)
elsif max_id
references.take_while {|status| status.id > max_id }.reverse.take(limit).reverse
else
references.take(limit)
end
end
return if @references.empty?
@max_id = @references.first&.id if @references.first.id != references.first.id
@min_id = @references.last&.id if @references.last.id != references.last.id
end
def set_ancestors
@ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
@references = @status.thread_references(
DESCENDANTS_LIMIT,
current_account,
params[:max_descendant_thread_id]&.to_i,
params[:since_descendant_thread_id]&.to_i,
DESCENDANTS_DEPTH_LIMIT
)
@ancestors = cache_collection(
@status.ancestors(ANCESTORS_LIMIT, current_account) + @references,
Status
).sort_by{|status| status.id}.take(ANCESTORS_LIMIT)
@next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
end

View file

@ -79,7 +79,12 @@ class Settings::PreferencesController < Settings::BaseController
:setting_disable_joke_appearance,
:setting_new_features_policy,
:setting_theme_instance_ticker,
notification_emails: %i(follow follow_request reblog favourite emoji_reaction mention digest report pending_account trending_tag),
:setting_enable_status_reference,
:setting_match_visibility_of_references,
:setting_post_reference_modal,
:setting_add_reference_modal,
:setting_unselect_reference_modal,
notification_emails: %i(follow follow_request reblog favourite emoji_reaction status_reference mention digest report pending_account trending_tag),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end

View file

@ -12,13 +12,13 @@ class StatusesController < ApplicationController
before_action :set_status
before_action :set_instance_presenter
before_action :set_link_headers
before_action :redirect_to_original, only: :show
before_action :set_referrer_policy_header, only: :show
before_action :redirect_to_original, only: [:show, :references]
before_action :set_referrer_policy_header, only: [:show, :references]
before_action :set_cache_headers
before_action :set_body_classes
skip_around_action :set_locale, if: -> { request.format == :json }
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
skip_before_action :require_functional!, only: [:show, :references, :embed], unless: :whitelist_mode?
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
@ -39,6 +39,20 @@ class StatusesController < ApplicationController
end
end
def references
respond_to do |format|
format.html do
expires_in 10.seconds, public: true if current_account.nil?
set_references
return not_found unless @references.present?
end
format.json do
redirect_to account_status_references_url(@account, @status)
end
end
end
def activity
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter

View file

@ -27,6 +27,7 @@ module ContextHelper
quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' },
expiry: { 'fedibird' => 'http://fedibird.com/ns#', 'expiry' => 'fedibird:expiry' },
other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' },
references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => "fedibird:references", '@type' => '@id' } },
is_cat: { 'misskey' => 'https://misskey-hub.net/ns#', 'isCat' => 'misskey:isCat' },
vcard: { 'vcard' => 'http://www.w3.org/2006/vcard/ns#' },
}.freeze

View file

@ -52,7 +52,7 @@ module JsonLdHelper
end
def same_origin?(url_a, url_b)
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host).zero?
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host)&.zero?
end
def invalid_origin?(url)

View file

@ -54,7 +54,18 @@ module StatusesHelper
components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')]
if status.spoiler_text.blank?
components << status.text
components << Formatter.instance.plaintext(status)
components << poll_summary(status)
end
components.reject(&:blank?).join("\n\n")
end
def reference_description(status)
components = [status_text_summary(status)]
if status.spoiler_text.blank?
components << [Formatter.instance.plaintext(status).chomp, media_summary(status)].reject(&:blank?).join(' · ')
components << poll_summary(status)
end
@ -113,6 +124,10 @@ module StatusesHelper
end
end
def noindex?(statuses)
statuses.map(&:account).uniq.any?(&:noindex?)
end
private
def simplified_text(text)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -13,6 +13,8 @@ import { showAlert } from './alerts';
import { openModal } from './modal';
import { defineMessages } from 'react-intl';
import { addYears, addMonths, addDays, addHours, addMinutes, addSeconds, millisecondsToSeconds, set, parseISO, formatISO } from 'date-fns';
import { Set as ImmutableSet } from 'immutable';
import { postReferenceModal } from '../initial_state';
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@ -80,9 +82,15 @@ export const COMPOSE_SCHEDULED_CHANGE = 'COMPOSE_SCHEDULED_CHANGE';
export const COMPOSE_EXPIRES_CHANGE = 'COMPOSE_EXPIRES_CHANGE';
export const COMPOSE_EXPIRES_ACTION_CHANGE = 'COMPOSE_EXPIRES_ACTION_CHANGE';
export const COMPOSE_REFERENCE_ADD = 'COMPOSE_REFERENCE_ADD';
export const COMPOSE_REFERENCE_REMOVE = 'COMPOSE_REFERENCE_REMOVE';
export const COMPOSE_REFERENCE_RESET = 'COMPOSE_REFERENCE_RESET';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
postReferenceMessage: { id: 'confirmations.post_reference.message', defaultMessage: 'It contains references, do you want to post it?' },
postReferenceConfirm: { id: 'confirmations.post_reference.confirm', defaultMessage: 'Post' },
});
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
@ -93,6 +101,16 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
}
};
export const getContextReference = (getState, status) => {
if (!status) {
return ImmutableSet();
}
const references = status.get('status_reference_ids').toSet();
const replyStatus = status.get('in_reply_to_id') ? getState().getIn(['statuses', status.get('in_reply_to_id')]) : null;
return references.concat(getContextReference(getState, replyStatus));
};
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@ -105,6 +123,7 @@ export function replyCompose(status, routerHistory) {
dispatch({
type: COMPOSE_REPLY,
status: status,
context_references: getContextReference(getState, status),
});
ensureComposeIsVisible(getState, routerHistory);
@ -203,6 +222,28 @@ export const getDateTimeFromText = (value, origin = new Date()) => {
};
};
export function submitComposeWithCheck(routerHistory, intl) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
const statusReferenceIds = getState().getIn(['compose', 'references']);
if ((!status || !status.length) && media.size === 0) {
return;
}
if (postReferenceModal && !statusReferenceIds.isEmpty()) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.postReferenceMessage),
confirm: intl.formatMessage(messages.postReferenceConfirm),
onConfirm: () => dispatch(submitCompose(routerHistory)),
}));
} else {
dispatch(submitCompose(routerHistory));
}
};
};
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
@ -212,6 +253,7 @@ export function submitCompose(routerHistory) {
const { in: scheduled_in = null, at: scheduled_at = null } = getDateTimeFromText(getState().getIn(['compose', 'scheduled']), new Date());
const { in: expires_in = null, at: expires_at = null } = getDateTimeFromText(getState().getIn(['compose', 'expires']), scheduled_at ?? new Date());
const expires_action = getState().getIn(['compose', 'expires_action']);
const statusReferenceIds = getState().getIn(['compose', 'references']);
if ((!status || !status.length) && media.size === 0) {
return;
@ -234,6 +276,7 @@ export function submitCompose(routerHistory) {
expires_at: !expires_in && expires_at ? formatISO(set(expires_at, { seconds: 59 })) : null,
expires_in: expires_in,
expires_action: expires_action,
status_reference_ids: statusReferenceIds,
}, {
headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -810,3 +853,34 @@ export function changeExpiresAction(value) {
value: value,
};
};
export function addReference(id, change) {
return (dispatch, getState) => {
if (change) {
const status = getState().getIn(['statuses', id]);
const visibility = getState().getIn(['compose', 'privacy']);
if (status && status.get('visibility') === 'private' && ['public', 'unlisted'].includes(visibility)) {
dispatch(changeComposeVisibility('private'));
}
}
dispatch({
type: COMPOSE_REFERENCE_ADD,
id: id,
});
};
};
export function removeReference(id) {
return {
type: COMPOSE_REFERENCE_REMOVE,
id: id,
};
};
export function resetReference() {
return {
type: COMPOSE_REFERENCE_RESET,
};
};

View file

@ -58,6 +58,7 @@ export function normalizeStatus(status, normalOldStatus) {
// Otherwise keep the ones already in the reducer
if (normalOldStatus) {
normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.shortHtml = normalOldStatus.get('shortHtml');
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
@ -73,11 +74,16 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoiler_text = '';
}
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
const docContentElem = domParser.parseFromString(searchContent, 'text/html').documentElement;
docContentElem.querySelector('.quote-inline')?.remove();
docContentElem.querySelector('.reference-link-inline')?.remove();
normalStatus.search_index = docContentElem.textContent;
normalStatus.shortHtml = '<p>'+emojify(normalStatus.search_index.substr(0, 150), emojiMap) + (normalStatus.search_index.substr(150) ? '...' : '')+'</p>';
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;

View file

@ -1,6 +1,6 @@
import api, { getLinks } from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchRelationships, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
import { me } from '../initial_state';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
@ -43,6 +43,14 @@ export const EMOJI_REACTIONS_EXPAND_REQUEST = 'EMOJI_REACTIONS_EXPAND_REQUEST';
export const EMOJI_REACTIONS_EXPAND_SUCCESS = 'EMOJI_REACTIONS_EXPAND_SUCCESS';
export const EMOJI_REACTIONS_EXPAND_FAIL = 'EMOJI_REACTIONS_EXPAND_FAIL';
export const REFERRED_BY_STATUSES_FETCH_REQUEST = 'REFERRED_BY_STATUSES_FETCH_REQUEST';
export const REFERRED_BY_STATUSES_FETCH_SUCCESS = 'REFERRED_BY_STATUSES_FETCH_SUCCESS';
export const REFERRED_BY_STATUSES_FETCH_FAIL = 'REFERRED_BY_STATUSES_FETCH_FAIL';
export const REFERRED_BY_STATUSES_EXPAND_REQUEST = 'REFERRED_BY_STATUSES_EXPAND_REQUEST';
export const REFERRED_BY_STATUSES_EXPAND_SUCCESS = 'REFERRED_BY_STATUSES_EXPAND_SUCCESS';
export const REFERRED_BY_STATUSES_EXPAND_FAIL = 'REFERRED_BY_STATUSES_EXPAND_FAIL';
export const MENTIONS_FETCH_REQUEST = 'MENTIONS_FETCH_REQUEST';
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL';
@ -337,6 +345,7 @@ export function fetchReblogsSuccess(id, accounts, next) {
export function fetchReblogsFail(id, error) {
return {
type: REBLOGS_FETCH_FAIL,
id,
error,
};
};
@ -381,6 +390,7 @@ export function expandReblogsSuccess(id, accounts, next) {
export function expandReblogsFail(id, error) {
return {
type: REBLOGS_EXPAND_FAIL,
id,
error,
};
};
@ -419,6 +429,7 @@ export function fetchFavouritesSuccess(id, accounts, next) {
export function fetchFavouritesFail(id, error) {
return {
type: FAVOURITES_FETCH_FAIL,
id,
error,
};
};
@ -463,6 +474,7 @@ export function expandFavouritesSuccess(id, accounts, next) {
export function expandFavouritesFail(id, error) {
return {
type: FAVOURITES_EXPAND_FAIL,
id,
error,
};
};
@ -501,6 +513,7 @@ export function fetchEmojiReactionsSuccess(id, emojiReactions, next) {
export function fetchEmojiReactionsFail(id, error) {
return {
type: EMOJI_REACTIONS_FETCH_FAIL,
id,
error,
};
};
@ -545,6 +558,95 @@ export function expandEmojiReactionsSuccess(id, emojiReactions, next) {
export function expandEmojiReactionsFail(id, error) {
return {
type: EMOJI_REACTIONS_EXPAND_FAIL,
id,
error,
};
};
export function fetchReferredByStatuses(id) {
return (dispatch, getState) => {
dispatch(fetchReferredByStatusesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/referred_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
const statuses = response.data;
dispatch(importFetchedStatuses(statuses));
dispatch(fetchRelationshipsFromStatuses(statuses));
dispatch(fetchAccountsFromStatuses(statuses));
dispatch(fetchReferredByStatusesSuccess(id, statuses, next ? next.uri : null));
}).catch(error => {
dispatch(fetchReferredByStatusesFail(id, error));
});
};
};
export function fetchReferredByStatusesRequest(id) {
return {
type: REFERRED_BY_STATUSES_FETCH_REQUEST,
id,
};
};
export function fetchReferredByStatusesSuccess(id, statuses, next) {
return {
type: REFERRED_BY_STATUSES_FETCH_SUCCESS,
id,
statuses,
next,
};
};
export function fetchReferredByStatusesFail(id, error) {
return {
type: REFERRED_BY_STATUSES_FETCH_FAIL,
id,
error,
};
};
export function expandReferredByStatuses(id) {
return (dispatch, getState) => {
const url = getState().getIn(['status_status_lists', 'referred_by', id, 'next'], null);
if (url === null || getState().getIn(['status_status_lists', 'referred_by', id, 'isLoading'])) {
return;
}
dispatch(expandReferredByStatusesRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
const statuses = response.data;
dispatch(importFetchedStatuses(statuses));
dispatch(fetchRelationshipsFromStatuses(statuses));
dispatch(fetchAccountsFromStatuses(statuses));
dispatch(expandReferredByStatusesSuccess(id, statuses, next ? next.uri : null));
}).catch(error => {
dispatch(expandReferredByStatusesFail(id, error));
});
};
};
export function expandReferredByStatusesRequest(id) {
return {
type: REFERRED_BY_STATUSES_EXPAND_REQUEST,
id,
};
};
export function expandReferredByStatusesSuccess(id, statuses, next) {
return {
type: REFERRED_BY_STATUSES_EXPAND_SUCCESS,
id,
statuses,
next,
};
};
export function expandReferredByStatusesFail(id, error) {
return {
type: REFERRED_BY_STATUSES_EXPAND_FAIL,
id,
error,
};
};
@ -583,6 +685,7 @@ export function fetchMentionsSuccess(id, accounts, next) {
export function fetchMentionsFail(id, error) {
return {
type: MENTIONS_FETCH_FAIL,
id,
error,
};
};
@ -627,6 +730,7 @@ export function expandMentionsSuccess(id, accounts, next) {
export function expandMentionsFail(id, error) {
return {
type: MENTIONS_EXPAND_FAIL,
id,
error,
};
};

View file

@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems, enableReaction } from 'mastodon/initial_state';
import { usePendingItems as preferPendingItems, enableReaction, enableStatusReference } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications';
@ -66,7 +66,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false;
if (['mention', 'status'].includes(notification.type)) {
if (['mention', 'status', 'status_reference'].includes(notification.type)) {
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status);
@ -120,10 +120,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', enableReaction ? 'emoji_reaction' : null, enableStatusReference ? 'status_reference' : null].filter(x => !!x))
const excludeTypesFromFilter = filter => {
const allTypes = enableReaction ?
ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'emoji_reaction']) :
ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};

View file

@ -3,12 +3,16 @@ import api from '../api';
import { deleteFromTimelines } from './timelines';
import { fetchRelationshipsFromStatus, fetchAccountsFromStatus, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { ensureComposeIsVisible } from './compose';
import { ensureComposeIsVisible, getContextReference } from './compose';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
export const STATUSES_FETCH_REQUEST = 'STATUSES_FETCH_REQUEST';
export const STATUSES_FETCH_SUCCESS = 'STATUSES_FETCH_SUCCESS';
export const STATUSES_FETCH_FAIL = 'STATUSES_FETCH_FAIL';
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
@ -83,12 +87,58 @@ export function fetchStatusFail(id, error, skipLoading) {
};
};
export function redraft(status, replyStatus, raw_text) {
export function fetchStatusesRequest(ids) {
return {
type: STATUSES_FETCH_REQUEST,
ids,
};
};
export function fetchStatuses(ids) {
return (dispatch, getState) => {
const loadedStatuses = getState().get('statuses', new Map());
const newStatusIds = Array.from(new Set(ids)).filter(id => loadedStatuses.get(id, null) === null);
if (newStatusIds.length === 0) {
return;
}
dispatch(fetchStatusesRequest(newStatusIds));
api(getState).get(`/api/v1/statuses?${newStatusIds.map(id => `ids[]=${id}`).join('&')}`).then(response => {
const statuses = response.data;
dispatch(importFetchedStatuses(statuses));
dispatch(fetchRelationshipsFromStatuses(statuses));
dispatch(fetchAccountsFromStatuses(statuses));
dispatch(fetchStatusesSuccess());
}).catch(error => {
dispatch(fetchStatusesFail(id, error));
});
};
};
export function fetchStatusesSuccess() {
return {
type: STATUSES_FETCH_SUCCESS,
};
};
export function fetchStatusesFail(id, error) {
return {
type: STATUSES_FETCH_FAIL,
id,
error,
skipAlert: true,
};
};
export function redraft(getState, status, replyStatus, raw_text) {
return {
type: REDRAFT,
status,
replyStatus,
raw_text,
context_references: getContextReference(getState, replyStatus),
};
};
@ -109,7 +159,8 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(importFetchedAccount(response.data.account));
if (withRedraft) {
dispatch(redraft(status, replyStatus, response.data.text));
dispatch(fetchStatuses(status.get('status_reference_ids', [])));
dispatch(redraft(getState, status, replyStatus, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
@ -144,12 +195,12 @@ export function fetchContext(id) {
return (dispatch, getState) => {
dispatch(fetchContextRequest(id));
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
const statuses = response.data.ancestors.concat(response.data.descendants);
api(getState).get(`/api/v1/statuses/${id}/context`, { params: { with_reference: true } }).then(response => {
const statuses = response.data.ancestors.concat(response.data.descendants).concat(response.data.references);
dispatch(importFetchedStatuses(statuses));
dispatch(fetchRelationshipsFromStatuses(statuses));
dispatch(fetchAccountsFromStatuses(statuses));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references));
}).catch(error => {
if (error.response && error.response.status === 404) {
@ -168,12 +219,13 @@ export function fetchContextRequest(id) {
};
};
export function fetchContextSuccess(id, ancestors, descendants) {
export function fetchContextSuccess(id, ancestors, descendants, references) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
references,
statuses: ancestors.concat(descendants),
};
};

View file

@ -2,21 +2,28 @@ import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, countMax, issueBadge, className }) => {
const formatNumber = num => num > countMax ? `${countMax}+` : num;
const IconWithBadge = ({ id, count, issueBadge, className }) => (
<i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i>
);
return (
<i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i>
)
};
IconWithBadge.propTypes = {
id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired,
countMax: PropTypes.number,
issueBadge: PropTypes.bool,
className: PropTypes.string,
};
IconWithBadge.defaultProps = {
countMax: 40,
};
export default IconWithBadge;

View file

@ -10,7 +10,6 @@ import DisplayName from './display_name';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import AccountActionBar from './account_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -20,7 +19,8 @@ import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { displayMedia, enableReaction } from 'mastodon/initial_state';
import { displayMedia, enableReaction, show_reply_tree_button, enableStatusReference } from 'mastodon/initial_state';
import { List as ImmutableList } from 'immutable';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@ -85,6 +85,9 @@ const messages = defineMessages({
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual-followers-only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
mark_ancestor: { id: 'thread_mark.ancestor', defaultMessage: 'Has reference' },
mark_descendant: { id: 'thread_mark.descendant', defaultMessage: 'Has reply' },
mark_both: { id: 'thread_mark.both', defaultMessage: 'Has reference and reply' },
});
const dateFormatOptions = {
@ -108,6 +111,8 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
quote_muted: PropTypes.bool,
onClick: PropTypes.func,
onReply: PropTypes.func,
@ -126,6 +131,7 @@ class Status extends ImmutablePureComponent {
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onQuoteToggleHidden: PropTypes.func,
onReference: PropTypes.func,
onAddToList: PropTypes.func.isRequired,
muted: PropTypes.bool,
hidden: PropTypes.bool,
@ -158,6 +164,8 @@ class Status extends ImmutablePureComponent {
'hidden',
'unread',
'pictureInPicture',
'referenced',
'contextReferenced',
'quote_muted',
];
@ -373,7 +381,7 @@ class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar, prepend, rebloggedByText;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted, referenced, contextReferenced } = this.props;
let { status, account, ...other } = this.props;
@ -685,17 +693,51 @@ class Status extends ImmutablePureComponent {
const expires_date = expires_at && new Date(expires_at)
const expired = expires_date && expires_date.getTime() < intl.now()
const ancestorCount = showThread && show_reply_tree_button && status.get('in_reply_to_id', 0) > 0 ? 1 : 0;
const descendantCount = showThread && show_reply_tree_button ? status.get('replies_count', 0) : 0;
const referenceCount = enableStatusReference ? status.get('status_references_count', 0) - (status.get('status_reference_ids', ImmutableList()).includes(status.get('quote_id')) ? 1 : 0) : 0;
const threadCount = ancestorCount + descendantCount + referenceCount;
let threadMarkTitle = '';
if (ancestorCount + referenceCount > 0) {
if (descendantCount > 0) {
threadMarkTitle = intl.formatMessage(messages.mark_both);
} else {
threadMarkTitle = intl.formatMessage(messages.mark_ancestor);
}
} else if (descendantCount > 0) {
threadMarkTitle = intl.formatMessage(messages.mark_descendant);
}
const threadMark = threadCount > 0 ? <span className={classNames('status__thread_mark', {
'status__thread_mark-ancenstor': (ancestorCount + referenceCount) > 0,
'status__thread_mark-descendant': descendantCount > 0,
})} title={threadMarkTitle}>+</span> : null;
return (
<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, 'status__wrapper-with-expiration': expires_date, 'status__wrapper-expired': expired })} 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,
'status__wrapper-with-expiration': expires_date,
'status__wrapper-expired': expired,
'status__wrapper-referenced': referenced,
'status__wrapper-context-referenced': contextReferenced,
'status__wrapper-reference': referenceCount > 0,
})} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired, referenced, 'context-referenced': contextReferenced })} data-id={status.get('id')}>
<AccountActionBar account={status.get('account')} {...other} />
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className="fa fa-clock-o" aria-hidden="true"></i></time></span>}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
{threadMark}
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
<span className='status__visibility-icon'>{visibilityLink}</span>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>

View file

@ -6,8 +6,9 @@ import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, isStaff, show_bookmark_button, show_quote_button, enableReaction } from '../initial_state';
import { me, isStaff, show_bookmark_button, show_quote_button, enableReaction, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from '../initial_state';
import classNames from 'classnames';
import { openModal } from '../actions/modal';
import ReactionPickerDropdownContainer from '../containers/reaction_picker_dropdown_container';
@ -23,6 +24,7 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -37,6 +39,7 @@ const messages = defineMessages({
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited users' },
show_emoji_reactions: { id: 'status.show_emoji_reactions', defaultMessage: 'Show emoji reactioned users' },
show_referred_by_statuses: { id: 'status.show_referred_by_statuses', defaultMessage: 'Show referred by statuses' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
@ -51,10 +54,17 @@ const messages = defineMessages({
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
});
const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
selected: state.getIn(['compose', 'references']).has(status.get('id')),
composePrivacy: state.getIn(['compose', 'privacy']),
});
export default @connect(mapStateToProps)
@ -68,7 +78,12 @@ class StatusActionBar extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expired: PropTypes.bool,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
relationship: ImmutablePropTypes.map,
referenceCountLimit: PropTypes.bool,
selected: PropTypes.bool,
composePrivacy: PropTypes.string,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
@ -88,6 +103,8 @@ class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
onBookmark: PropTypes.func,
onAddReference: PropTypes.func,
onRemoveReference: PropTypes.func,
withDismiss: PropTypes.bool,
scrollKey: PropTypes.string,
intl: PropTypes.object.isRequired,
@ -105,6 +122,9 @@ class StatusActionBar extends ImmutablePureComponent {
'status',
'relationship',
'withDismiss',
'referenced',
'contextReferenced',
'referenceCountLimit'
]
handleReplyClick = () => {
@ -140,6 +160,31 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
handleReferenceClick = (e) => {
const { dispatch, intl, status, selected, composePrivacy, onAddReference, onRemoveReference } = this.props;
const id = status.get('id');
if (selected) {
onRemoveReference(id);
} else {
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
if (!addReferenceModal || e && e.shiftKey) {
onAddReference(id, true);
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
onConfirm: () => onAddReference(id, matchVisibilityOfReferences),
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
onSecondary: () => onAddReference(id, !matchVisibilityOfReferences),
}));
}
} else {
onAddReference(id, true);
}
}
}
_openInteractionDialog = type => {
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
}
@ -266,6 +311,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/emoji_reactions`);
}
handleReferredByStatuses = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/referred_by`);
}
handleEmojiPick = data => {
const { addEmojiReaction, status } = this.props;
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
@ -277,7 +326,7 @@ class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, relationship, intl, withDismiss, scrollKey, expired } = this.props;
const { status, relationship, intl, withDismiss, scrollKey, expired, referenced, contextReferenced, referenceCountLimit } = this.props;
const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -290,6 +339,7 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count');
const [ _, domain ] = account.get('acct').split('@');
@ -318,6 +368,10 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.show_emoji_reactions), action: this.handleEmojiReactions });
}
if (enableStatusReference && referredByCount > 0) {
menu.push({ text: intl.formatMessage(messages.show_referred_by_statuses), action: this.handleReferredByStatuses });
}
if (domain) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
@ -413,9 +467,12 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' disabled={expired} title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={expired} 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 />
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={reblogged} pressed={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={!favourited && expired} active={favourited} pressed={favourited} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{show_quote_button && <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}

View file

@ -6,7 +6,7 @@ import Permalink from './permalink';
import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon';
import { autoPlayGif, show_reply_tree_button } from 'mastodon/initial_state';
import { autoPlayGif } from 'mastodon/initial_state';
const messages = defineMessages({
linkToAcct: { id: 'status.link_to_acct', defaultMessage: 'Link to @{acct}' },
@ -39,13 +39,22 @@ export default class StatusContent extends React.PureComponent {
};
_updateStatusLinks () {
const { intl, status, collapsable, onClick, onCollapsedToggle } = this.props;
const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a');
const reference_link = node.querySelector('.reference-link-inline > a');
if (reference_link && reference_link?.dataset?.statusId && !reference_link.hasReferenceClick ) {
reference_link.addEventListener('click', this.onReferenceLinkClick.bind(this, reference_link.dataset.statusId), false);
reference_link.setAttribute('target', '_blank');
reference_link.setAttribute('rel', 'noopener noreferrer');
reference_link.hasReferenceClick = true;
}
const links = node.querySelectorAll(':not(.reference-link-inline) > a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
@ -54,7 +63,7 @@ export default class StatusContent extends React.PureComponent {
}
link.classList.add('status-link');
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
let mention = status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
if (mention.get('group', false)) {
@ -66,10 +75,10 @@ export default class StatusContent extends React.PureComponent {
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else if (link.classList.contains('account-url-link')) {
link.setAttribute('title', this.props.intl.formatMessage(messages.linkToAcct, { acct: link.dataset.accountAcct }));
link.setAttribute('title', intl.formatMessage(messages.linkToAcct, { acct: link.dataset.accountAcct }));
link.addEventListener('click', this.onAccountUrlClick.bind(this, link.dataset.accountId, link.dataset.accountActorType), false);
} else if (link.classList.contains('status-url-link')) {
link.setAttribute('title', this.props.intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct }));
} else if (link.classList.contains('status-url-link') && ![status.get('uri'), status.get('url')].includes(link.href)) {
link.setAttribute('title', intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct }));
link.addEventListener('click', this.onStatusUrlClick.bind(this, link.dataset.statusId), false);
} else {
link.setAttribute('title', link.href);
@ -80,16 +89,16 @@ export default class StatusContent extends React.PureComponent {
link.setAttribute('rel', 'noopener noreferrer');
}
if (this.props.status.get('collapsed', null) === null) {
if (status.get('collapsed', null) === null) {
let collapsed =
this.props.collapsable
&& this.props.onClick
collapsable
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& this.props.status.get('spoiler_text').length === 0;
&& status.get('spoiler_text').length === 0;
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
if(onCollapsedToggle) onCollapsedToggle(collapsed);
this.props.status.set('collapsed', collapsed);
status.set('collapsed', collapsed);
}
}
@ -173,6 +182,13 @@ export default class StatusContent extends React.PureComponent {
}
}
onReferenceLinkClick = (statusId, e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/statuses/${statusId}/references`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
@ -221,8 +237,7 @@ export default class StatusContent extends React.PureComponent {
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
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']) ||
show_reply_tree_button && (status.get('in_reply_to_id') || !!status.get('replies_count'))
status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])
);
const renderShowPoll = !!status.get('poll');

View file

@ -0,0 +1,114 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { ThumbnailGallery } from '../features/ui/util/async-components';
import classNames from 'classnames';
import IconButton from '../components/icon_button';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
const messages = defineMessages({
unselect: { id: 'reference_stack.unselect', defaultMessage: 'Unselecting a post' },
});
export default @injectIntl
class StatusItem extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
onClick: PropTypes.func,
onUnselectReference: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
};
updateOnProps = [
'status',
];
handleClick = () => {
if (this.props.onClick) {
this.props.onClick();
return;
}
if (!this.context.router) {
return;
}
const { status } = this.props;
this.context.router.history.push(`/statuses/${status.get('id')}`);
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
const group = e.currentTarget.getAttribute('data-group') !== 'false';
e.preventDefault();
e.stopPropagation();
if (group) {
this.context.router.history.push(`/timelines/groups/${id}`);
} else {
this.context.router.history.push(`/accounts/${id}`);
}
}
}
handleUnselectClick = (e) => {
const { status, onUnselectReference } = this.props;
const id = status.get('id');
e.stopPropagation();
onUnselectReference(id, e);
}
handleRef = c => {
this.node = c;
}
render () {
const { intl, status } = this.props;
if (status === null) {
return null;
}
return (
<div className={classNames('mini-status__wrapper', `mini-status__wrapper-${status.get('visibility')}`, { 'mini-status__wrapper-reply': !!status.get('in_reply_to_id') })} tabIndex={0} ref={this.handleRef}>
<div className={classNames('mini-status', `mini-status-${status.get('visibility')}`, { 'mini-status-reply': !!status.get('in_reply_to_id') })} onClick={this.handleClick} data-id={status.get('id')}>
<div className='mini-status__account'>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='mini-status__avatar'>
<Avatar account={status.get('account')} size={24} />
</div>
</a>
</div>
<div className='mini-status__content'>
<div className='mini-status__content__text translate' dangerouslySetInnerHTML={{__html: status.get('shortHtml')}} />
<Bundle fetchComponent={ThumbnailGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
/>
)}
</Bundle>
</div>
<div className='mini-status__unselect'><IconButton title={intl.formatMessage(messages.unselect)} icon='times' onClick={this.handleUnselectClick} /></div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,166 @@
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { is } from 'immutable';
import classNames from 'classnames';
import { displayMedia, useBlurhash } from '../initial_state';
import Blurhash from 'mastodon/components/blurhash';
import { FormattedMessage } from 'react-intl';
class Item extends React.PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
visible: PropTypes.bool.isRequired,
autoplay: PropTypes.bool,
};
state = {
loaded: false,
};
handleImageLoad = () => {
this.setState({ loaded: true });
}
render () {
const { attachment, visible } = this.props;
let thumbnail = '';
let typeLabel = '';
const id = attachment.get('id');
const type = attachment.get('type');
const hash = attachment.get('blurhash');
const preview = attachment.get('preview_url');
const description = attachment.get('description');
switch(type) {
case 'image':
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
thumbnail = (
<img
className='thumbnail-gallery__item-thumbnail'
src={preview}
alt={description}
title={description}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
/>
);
break;
case 'gifv':
thumbnail = (
<div className='thumbnail-gallery__gifv'>
<video
className='thumbnail-gallery__item-gifv-thumbnail'
aria-label={description}
title={description}
role='application'
src={attachment.get('url')}
autoPlay={false}
muted
/>
</div>
);
typeLabel = <FormattedMessage id='thumbnail.type.gif' defaultMessage='(GIF)' />;
break;
case 'audio':
thumbnail = preview ? (
<img
className='thumbnail-gallery__item-thumbnail'
src={preview}
alt={description}
title={description}
onLoad={this.handleImageLoad}
/>
) : null;
typeLabel = <FormattedMessage id='thumbnail.type.audio' defaultMessage='(Audio)' />;
break;
case 'video':
thumbnail = (
<img
className='thumbnail-gallery__item-thumbnail'
src={preview}
alt={description}
title={description}
onLoad={this.handleImageLoad}
/>
);
typeLabel = <FormattedMessage id='thumbnail.type.video' defaultMessage='(Video)' />;
break;
default:
return hash ? (
<div className='thumbnail-gallery__item' key={id}>
<Blurhash
hash={hash}
className='thumbnail-gallery__preview'
dummy={!useBlurhash}
/>
</div>
) : null;
}
return (
<Fragment>
<div className='thumbnail-gallery__item' key={id}>
{hash && <Blurhash
hash={hash}
dummy={!useBlurhash}
className={classNames('thumbnail-gallery__preview', {
'thumbnail-gallery__preview--hidden': visible && this.state.loaded,
})}
/>}
{visible && thumbnail}
</div>
{typeLabel && <div className='thumbnail-gallery__type' key='type'>{typeLabel}</div>}
</Fragment>
);
}
}
export default
class ThumbnailGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
visible: PropTypes.bool,
};
state = {
visible: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
};
componentWillReceiveProps (nextProps) {
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ visible: nextProps.visible });
}
}
render () {
const { media } = this.props;
const { visible } = this.state;
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
const children = media.take(4).map((attachment) => <Item key={attachment.get('id')} attachment={attachment} visible={visible || uncached} />);
return (
<div className='thumbnail-gallery' ref={this.handleRef}>
{children}
</div>
);
}
}

View file

@ -7,6 +7,8 @@ import {
quoteCompose,
mentionCompose,
directCompose,
addReference,
removeReference,
} from '../actions/compose';
import {
reblog,
@ -74,12 +76,21 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
pictureInPicture: getPictureInPicture(state, props),
emojiMap: customEmojiMap(state),
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, props);
const id = !!status ? getProper(status).get('id') : null;
return {
status,
pictureInPicture: getPictureInPicture(state, props),
emojiMap: customEmojiMap(state),
id,
referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
}
};
return mapStateToProps;
};
@ -308,6 +319,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(removeEmojiReaction(status));
},
onAddReference (id, change) {
dispatch(addReference(id, change));
},
onRemoveReference (id) {
dispatch(removeReference(id));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View file

@ -0,0 +1,52 @@
import { connect } from 'react-redux';
import StatusItem from '../components/status_item';
import { makeGetStatus } from '../selectors';
import {
removeReference,
} from '../actions/compose';
import { openModal } from '../actions/modal';
import { unselectReferenceModal } from '../initial_state';
import { injectIntl, defineMessages } from 'react-intl';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const messages = defineMessages({
unselectMessage: { id: 'confirmations.unselect.message', defaultMessage: 'Are you sure you want to unselect a reference?' },
unselectConfirm: { id: 'confirmations.unselect.confirm', defaultMessage: 'Unselect' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const mapStateToProps = (state, props) => {
const status = getStatus(state, props);
return {
status,
emojiMap: customEmojiMap(state),
}
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onUnselectReference (id, e) {
if (!unselectReferenceModal || e && e.shiftKey) {
dispatch(removeReference(id));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.unselectMessage),
confirm: intl.formatMessage(messages.unselectConfirm),
onConfirm: () => dispatch(removeReference(id)),
}));
}
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(StatusItem));

View file

@ -19,6 +19,7 @@ import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import ReferenceStack from '../../../features/reference_stack';
import { isMobile } from '../../../is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
@ -273,6 +274,8 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
</div>
<ReferenceStack />
</div>
);
}

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import {
changeCompose,
submitCompose,
submitComposeWithCheck,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
@ -10,6 +10,7 @@ import {
insertEmojiCompose,
uploadCompose,
} from '../../../actions/compose';
import { injectIntl } from 'react-intl';
const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
@ -28,14 +29,14 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
});
const mapDispatchToProps = (dispatch) => ({
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit (router) {
dispatch(submitCompose(router));
dispatch(submitComposeWithCheck(router, intl));
},
onClearSuggestions () {
@ -64,4 +65,4 @@ const mapDispatchToProps = (dispatch) => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));

View file

@ -1,13 +1,14 @@
import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose';
import { submitCompose } from '../../../actions/compose';
import { submitComposeWithCheck } from '../../../actions/compose';
import { injectIntl } from 'react-intl';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = dispatch => ({
const mapDispatchToProps = (dispatch, { intl }) => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
@ -18,9 +19,9 @@ const mapDispatchToProps = dispatch => ({
},
onSubmit (router) {
dispatch(submitCompose(router));
dispatch(submitComposeWithCheck(router, intl));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Upload));

View file

@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button';
import SettingToggle from './setting_toggle';
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
export default class ColumnSettings extends React.PureComponent {
@ -153,16 +154,30 @@ export default class ColumnSettings extends React.PureComponent {
</div>
</div>
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span>
{enableReaction &&
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} />
</div>
</div>
}
{enableStatusReference &&
<div role='group' aria-labelledby='notifications-status-reference'>
<span id='notifications-status-reference' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status_reference' defaultMessage='Status references:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} />
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status_reference']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status_reference']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status_reference']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status_reference']} onChange={onChange} label={soundStr} />
</div>
</div>
}
</div>
);
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
@ -11,6 +12,7 @@ const tooltips = defineMessages({
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
reactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Reactions' },
reference: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
});
export default @injectIntl
@ -96,13 +98,24 @@ class FilterBar extends React.PureComponent {
>
<Icon id='home' fixedWidth />
</button>
<button
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
onClick={this.onClick('emoji_reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='smile-o' fixedWidth />
</button>
{enableReaction &&
<button
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
onClick={this.onClick('emoji_reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='smile-o' fixedWidth />
</button>
}
{enableStatusReference &&
<button
className={selectedFilter === 'status_reference' ? 'active' : ''}
onClick={this.onClick('status_reference')}
title={intl.formatMessage(tooltips.reference)}
>
<Icon id='link' fixedWidth />
</button>
}
<button
className={selectedFilter === 'follow' ? 'active' : ''}
onClick={this.onClick('follow')}

View file

@ -21,6 +21,7 @@ const messages = defineMessages({
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' },
status_reference: { id: 'notification.status_reference', defaultMessage: '{name} referenced your post' },
});
const notificationForScreenReader = (intl, message, timestamp) => {
@ -350,6 +351,38 @@ class Notification extends ImmutablePureComponent {
);
}
renderStatusReference (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-status-reference focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status_reference, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='link' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.status_reference' defaultMessage='{name} referenced your post' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
render () {
const { notification } = this.props;
const account = notification.get('account');
@ -373,6 +406,8 @@ class Notification extends ImmutablePureComponent {
return this.renderPoll(notification, account);
case 'emoji_reaction':
return this.renderReaction(notification, link);
case 'status_reference':
return this.renderStatusReference(notification, link);
}
return null;

View file

@ -5,9 +5,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import classNames from 'classnames';
import { me, boostModal, show_quote_button } from 'mastodon/initial_state';
import { me, boostModal, show_quote_button, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from 'mastodon/initial_state';
import { defineMessages, injectIntl } from 'react-intl';
import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
import { replyCompose, quoteCompose, addReference, removeReference } from 'mastodon/actions/compose';
import { reblog, favourite, bookmark, unreblog, unfavourite, unbookmark } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors';
import { initBoostModal } from 'mastodon/actions/boosts';
@ -16,6 +16,7 @@ import { openModal } from 'mastodon/actions/modal';
const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -29,15 +30,29 @@ const messages = defineMessages({
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' },
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const mapStateToProps = (state, { statusId }) => ({
status: getStatus(state, { id: statusId }),
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
});
const mapStateToProps = (state, { statusId }) => {
const status = getStatus(state, { id: statusId });
const id = status ? getProper(status).get('id') : null;
return {
status,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
composePrivacy: state.getIn(['compose', 'privacy']),
};
};
return mapStateToProps;
};
@ -53,6 +68,10 @@ class Footer extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
status: ImmutablePropTypes.map.isRequired,
referenceCountLimit: PropTypes.bool,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
composePrivacy: PropTypes.string,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
askReplyConfirmation: PropTypes.bool,
@ -85,6 +104,39 @@ class Footer extends ImmutablePureComponent {
}
};
handleReferenceClick = (e) => {
const { dispatch, intl, status, referenced, composePrivacy } = this.props;
const id = status.get('id');
if (referenced) {
this.handleRemoveReference(id);
} else {
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
if (!addReferenceModal || e && e.shiftKey) {
this.handleAddReference(id, true);
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
onConfirm: () => this.handleAddReference(id, matchVisibilityOfReferences),
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
onSecondary: () => this.handleAddReference(id, !matchVisibilityOfReferences),
}));
}
} else {
this.handleAddReference(id, true);
}
}
}
handleAddReference = (id, change) => {
this.props.dispatch(addReference(id, change));
}
handleRemoveReference = (id) => {
this.props.dispatch(removeReference(id));
}
handleFavouriteClick = () => {
const { dispatch, status } = this.props;
@ -164,7 +216,7 @@ class Footer extends ImmutablePureComponent {
}
render () {
const { status, intl, withOpenButton } = this.props;
const { status, intl, withOpenButton, referenced, contextReferenced, referenceCountLimit } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
@ -195,9 +247,12 @@ class Footer extends ImmutablePureComponent {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
return (
<div className='picture-in-picture__footer'>
<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 />
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button', 'link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
<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')} />
{show_quote_button && <IconButton className='status__action-bar-button' disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}

View file

@ -42,6 +42,7 @@ export default class Header extends ImmutablePureComponent {
<NavLink exact to={`/statuses/${status.get('id')}/reblogs`}><FormattedMessage id='status.reblog' defaultMessage='Boost' /></NavLink>
<NavLink exact to={`/statuses/${status.get('id')}/favourites`}><FormattedMessage id='status.favourite' defaultMessage='Favourite' /></NavLink>
<NavLink exact to={`/statuses/${status.get('id')}/emoji_reactions`}><FormattedMessage id='status.emoji' defaultMessage='Emoji' /></NavLink>
<NavLink exact to={`/statuses/${status.get('id')}/referred_by`}><FormattedMessage id='status.referred_by' defaultMessage='Referred' /></NavLink>
</div>
)}
</div>

View file

@ -0,0 +1,105 @@
import React, {Fragment} from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StackHeader from '../ui/components/stack_header';
import ReferencesCounterIcon from '../ui/components/references_counter_icon';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { openModal } from '../../actions/modal';
import Icon from 'mastodon/components/icon';
import StatusItemContainer from '../../containers/status_item_container';
import {
resetReference,
} from '../../actions/compose';
import { enableStatusReference } from '../../initial_state';
const messages = defineMessages({
clearMessage: { id: 'confirmations.clear.message', defaultMessage: 'Are you sure you want to clear this post reference lists?' },
clearConfirm: { id: 'confirmations.clear.confirm', defaultMessage: 'Clear' },
});
const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => {
const statusIds = state.getIn(['compose', 'references']).toList().sort((a, b) => b - a);
return {
statusIds,
};
};
return mapStateToProps;
};
export default @injectIntl
@connect(makeMapStateToProps)
class ReferenceStack extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
statusIds: ImmutablePropTypes.list,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
handleClearClick = (e) => {
const { dispatch, intl } = this.props;
if (e && e.shiftKey) {
dispatch(resetReference());
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(resetReference()),
}));
}
}
handleHeaderClick = () => {}
handleLoadMore = () => {}
render () {
const { statusIds, intl } = this.props;
if (!enableStatusReference || statusIds.isEmpty()) {
return <Fragment></Fragment>;
}
const title = (
<Fragment>
<ReferencesCounterIcon className='reference-stack__icon' /><FormattedMessage id='reference_stack.header' defaultMessage='Selected reference postings' />
</Fragment>
);
const extraButton = (
<button
aria-label={intl.formatMessage(messages.clearConfirm)}
title={intl.formatMessage(messages.clearConfirm)}
onClick={this.handleClearClick}
className='stack-header__button'
>
<Icon id='eraser' />
</button>
);
return (
<div className='reference-stack'>
<StackHeader
title={title}
onClick={this.handleHeaderClick}
extraButton={extraButton}
/>
<div className='reference-stack__list'>
{statusIds.map(statusId => <StatusItemContainer key={statusId} id={statusId} />)}
</div>
</div>
);
}
}

View file

@ -0,0 +1,98 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchReferredByStatuses, expandReferredByStatuses } from '../../actions/interactions';
import Column from '../ui/components/column';
import Icon from 'mastodon/components/icon';
import ColumnHeader from '../../components/column_header';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ReactedHeaderContaier from '../reactioned/containers/header_container';
import { debounce } from 'lodash';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
heading: { id: 'column.referred_by_statuses', defaultMessage: 'Referred by posts' },
});
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['status_status_lists', 'referred_by', props.params.statusId, 'items']),
isLoading: state.getIn(['status_status_lists', 'referred_by', props.params.statusId, 'isLoading'], true),
hasMore: !!state.getIn(['status_status_lists', 'referred_by', props.params.statusId, 'next']),
});
export default @connect(mapStateToProps)
@injectIntl
class ReferredByStatuses extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
if (!this.props.statusIds) {
this.props.dispatch(fetchReferredByStatuses(this.props.params.statusId));
}
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchEmojiReactions(nextProps.params.statusId));
}
}
handleRefresh = () => {
this.props.dispatch(fetchReferredByStatuses(this.props.params.statusId));
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandReferredByStatuses(this.props.params.statusId));
}, 300, { leading: true })
render () {
const { intl, statusIds, multiColumn, hasMore, isLoading } = this.props;
if (!statusIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.referred_by_statuses' defaultMessage="There are no referred by posts yet. When someone refers a post, it will appear here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
)}
/>
<ReactedHeaderContaier statusId={this.props.params.statusId} />
<StatusList
statusIds={statusIds}
scrollKey='referred-by-statuses'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View file

@ -5,9 +5,10 @@ import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me, isStaff, show_quote_button, enableReaction } from '../../../initial_state';
import { me, isStaff, show_quote_button, enableReaction, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from '../../../initial_state';
import classNames from 'classnames';
import ReactionPickerDropdownContainer from 'mastodon/containers/reaction_picker_dropdown_container';
import { openModal } from '../../../actions/modal';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -16,6 +17,7 @@ const messages = defineMessages({
showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -28,6 +30,7 @@ const messages = defineMessages({
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited users' },
show_emoji_reactions: { id: 'status.show_emoji_reactions', defaultMessage: 'Show emoji reactioned users' },
show_referred_by_statuses: { id: 'status.show_referred_by_statuses', defaultMessage: 'Show referred by statuses' },
more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@ -46,10 +49,17 @@ const messages = defineMessages({
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
});
const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
selected: state.getIn(['compose', 'references']).has(status.get('id')),
composePrivacy: state.getIn(['compose', 'privacy']),
});
export default @connect(mapStateToProps)
@ -62,12 +72,19 @@ class ActionBar extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
relationship: ImmutablePropTypes.map,
referenceCountLimit: PropTypes.bool,
selected: PropTypes.bool,
composePrivacy: PropTypes.string,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onAddReference: PropTypes.func,
onRemoveReference: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onMemberList: PropTypes.func.isRequired,
@ -95,6 +112,31 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e);
}
handleReferenceClick = (e) => {
const { dispatch, intl, status, selected, composePrivacy, onAddReference, onRemoveReference } = this.props;
const id = status.get('id');
if (selected) {
onRemoveReference(id);
} else {
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
if (!addReferenceModal || e && e.shiftKey) {
onAddReference(id, true);
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
onConfirm: () => onAddReference(id, matchVisibilityOfReferences),
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
onSecondary: () => onAddReference(id, !matchVisibilityOfReferences),
}));
}
} else {
onAddReference(id, true);
}
}
}
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history);
}
@ -224,6 +266,10 @@ class ActionBar extends React.PureComponent {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/emoji_reactions`);
}
handleReferredByStatuses = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/referred_by`);
}
handleEmojiPick = data => {
const { addEmojiReaction, status } = this.props;
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
@ -235,7 +281,7 @@ class ActionBar extends React.PureComponent {
}
render () {
const { status, relationship, intl } = this.props;
const { status, relationship, intl, referenced, contextReferenced, referenceCountLimit } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
@ -247,6 +293,7 @@ class ActionBar extends React.PureComponent {
const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count');
const [ _, domain ] = account.get('acct').split('@');
@ -277,6 +324,10 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.show_emoji_reactions), action: this.handleEmojiReactions });
}
if (enableStatusReference && referredByCount > 0) {
menu.push({ text: intl.formatMessage(messages.show_referred_by_statuses), action: this.handleReferredByStatuses });
}
if (domain) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
@ -361,9 +412,12 @@ class ActionBar extends React.PureComponent {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton disabled={expired} title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
{enableStatusReference && me && <div className='detailed-status__button'><IconButton className={classNames('link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} /></div>}
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={favourited} disabled={!favourited && expired} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{show_quote_button && <div className='detailed-status__button'><IconButton disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>}

View file

@ -171,6 +171,24 @@ export default class Card extends React.PureComponent {
this.setState({ revealed: true });
}
handleOpen = e => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const { card } = this.props;
const account_id = card.get('account_id', null);
const status_id = card.get('status_id', null);
const url_suffix = card.get('url').endsWith('/references') ? '/references' : '';
e.preventDefault();
e.stopPropagation();
if (status_id) {
this.context.router.history.push(`/statuses/${status_id}${url_suffix}`);
} else {
this.context.router.history.push(`/accounts/${account_id}`);
}
}
}
renderVideo () {
const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) };
@ -288,7 +306,7 @@ export default class Card extends React.PureComponent {
}
return (
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' onClick={card.get('account_id', null) ? this.handleOpen : null} ref={this.setRef}>
{embed}
{description}
</a>

View file

@ -18,7 +18,7 @@ import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { enableReaction } from 'mastodon/initial_state';
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -71,6 +71,8 @@ class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
quote_muted: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
@ -93,6 +95,7 @@ class DetailedStatus extends ImmutablePureComponent {
emojiMap: ImmutablePropTypes.map,
addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired,
onReference: PropTypes.func,
};
state = {
@ -177,22 +180,24 @@ class DetailedStatus extends ImmutablePureComponent {
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 { intl, compact, pictureInPicture } = this.props;
const { intl, compact, pictureInPicture, referenced, contextReferenced } = this.props;
if (!status) {
return null;
}
let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
let emojiReactionLink = '';
let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
let emojiReactionLink = '';
let statusReferredByLink = '';
const reblogsCount = status.get('reblogs_count');
const favouritesCount = status.get('favourites_count');
const emojiReactionsCount = status.get('emoji_reactions_count');
const statusReferredByCount = status.get('status_referred_by_count');
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
@ -387,41 +392,55 @@ class DetailedStatus extends ImmutablePureComponent {
if (this.context.router) {
favouriteLink = (
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={favouritesCount} />
</span>
</Link>
<Fragment>
<Fragment> · </Fragment>
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={favouritesCount} />
</span>
</Link>
</Fragment>
);
} else {
favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={favouritesCount} />
</span>
</a>
<Fragment>
<Fragment> · </Fragment>
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={favouritesCount} />
</span>
</a>
</Fragment>
);
}
if (this.context.router) {
if (enableReaction && this.context.router) {
emojiReactionLink = (
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<Icon id='smile-o' />
<span className='detailed-status__emoji_reactions'>
<AnimatedNumber value={emojiReactionsCount} />
</span>
</Link>
<Fragment>
<Fragment> · </Fragment>
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<Icon id='smile-o' />
<span className='detailed-status__emoji_reactions'>
<AnimatedNumber value={emojiReactionsCount} />
</span>
</Link>
</Fragment>
);
} else {
emojiReactionLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='smile-o' />
<span className='detailed-status__emoji_reactions'>
<AnimatedNumber value={emojiReactionsCount} />
</span>
</a>
}
if (enableStatusReference && this.context.router) {
statusReferredByLink = (
<Fragment>
<Fragment> · </Fragment>
<Link to={`/statuses/${status.get('id')}/referred_by`} className='detailed-status__link'>
<Icon id='link' />
<span className='detailed-status__status_referred_by'>
<AnimatedNumber value={statusReferredByCount} />
</span>
</Link>
</Fragment>
);
}
@ -431,7 +450,7 @@ class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, 'detailed-status-with-expiration': expires_date, 'detailed-status-expired': expired })}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, 'detailed-status-with-expiration': expires_date, 'detailed-status-expired': expired, referenced, 'context-referenced': contextReferenced })}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
@ -460,7 +479,7 @@ class DetailedStatus extends ImmutablePureComponent {
</time>
</span>
}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionLink}
{visibilityLink}{applicationLink}{reblogLink}{favouriteLink}{emojiReactionLink}{statusReferredByLink}
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
export default class Header extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
hasReference: PropTypes.bool,
hideTabs: PropTypes.bool,
};
render () {
const { status, hasReference, hideTabs } = this.props;
if (status === null || !hasReference) {
return null;
}
return (
<div className='detailed-status__header'>
{!hideTabs && (
<div className='detailed-status__section-headline'>
<NavLink exact to={`/statuses/${status.get('id')}`}><FormattedMessage id='status.thread_with_references' defaultMessage='Thread' /></NavLink>
<NavLink exact to={`/statuses/${status.get('id')}/references`}><FormattedMessage id='status.reference' defaultMessage='Reference' /></NavLink>
</div>
)}
</div>
);
}
}

View file

@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { makeGetStatus } from '../../../selectors';
import Header from '../components/header';
import { injectIntl } from 'react-intl';
import { enableStatusReference } from 'mastodon/initial_state';
import { List as ImmutableList } from 'immutable';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { statusId }) => {
const status = getStatus(state, { id: statusId });
const hasReference = !!(status && enableStatusReference && (status.get('status_references_count', 0) - (status.get('status_reference_ids', ImmutableList()).includes(status.get('quote_id')) > 0)));
return {
status,
hasReference,
};
};
return mapStateToProps;
};
export default injectIntl(connect(makeMapStateToProps)(Header));

View file

@ -1,10 +1,9 @@
import Immutable from 'immutable';
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map as ImmutableMap } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
@ -28,6 +27,8 @@ import {
quoteCompose,
mentionCompose,
directCompose,
addReference,
removeReference,
} from '../../actions/compose';
import {
muteStatus,
@ -59,10 +60,11 @@ import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import { boostModal, deleteModal } from '../../initial_state';
import { boostModal, deleteModal, enableStatusReference } from '../../initial_state';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon';
import DetailedHeaderContaier from './containers/header_container';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -83,12 +85,13 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const getAncestorsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List();
let ancestorsIds = ImmutableList();
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId;
@ -135,28 +138,33 @@ const makeMapStateToProps = () => {
});
}
return Immutable.List(descendantsIds);
return ImmutableList(descendantsIds);
});
const getReferencesIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'references']),
], (statusId, contextReference) => {
return ImmutableList(contextReference.get(statusId));
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
}
const status = getStatus(state, { id: props.params.statusId });
const ancestorsIds = status ? getAncestorsIds(state, { id: status.get('in_reply_to_id') }) : ImmutableList();
const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList();
const referencesIds = status ? getReferencesIds(state, { id: status.get('id') }) : ImmutableList();
const id = status ? getProper(status).get('id') : null;
return {
status,
ancestorsIds,
ancestorsIds: ancestorsIds.concat(referencesIds).sortBy(id => id),
descendantsIds,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
emojiMap: customEmojiMap(state),
referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
};
};
@ -177,6 +185,8 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
intl: PropTypes.object.isRequired,
askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
@ -465,37 +475,34 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(removeEmojiReaction(status));
}
handleMoveUp = id => {
handleAddReference = (id, change) => {
this.props.dispatch(addReference(id, change));
}
handleRemoveReference = (id) => {
this.props.dispatch(removeReference(id));
}
getCurrentStatusIndex = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = ImmutableList([status.get('id')]);
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size - 1, true);
} else {
let index = ancestorsIds.indexOf(id);
return ImmutableList().concat(ancestorsIds, statusIds, descendantsIds).indexOf(id);
}
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index, true);
} else {
this._selectChild(index - 1, true);
}
handleMoveUp = id => {
const index = this.getCurrentStatusIndex(id);
if (index !== -1) {
return this._selectChild(index - 1, true);
}
}
handleMoveDown = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
const index = this.getCurrentStatusIndex(id);
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size + 1, false);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index + 2, false);
} else {
this._selectChild(index + 1, false);
}
if (index !== -1) {
return this._selectChild(index + 1, true);
}
}
@ -556,7 +563,7 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap } = this.props;
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap, referenced, contextReferenced } = this.props;
const { fullscreen } = this.state;
if (status === null) {
@ -576,6 +583,8 @@ class Status extends ImmutablePureComponent {
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
}
const referenceCount = enableStatusReference ? status.get('status_references_count', 0) - (status.get('status_reference_ids', ImmutableList()).includes(status.get('quote_id')) ? 1 : 0) : 0;
const handlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
@ -599,15 +608,23 @@ class Status extends ImmutablePureComponent {
)}
/>
<DetailedHeaderContaier statusId={status.get('id')} />
<ScrollContainer scrollKey='thread'>
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
{ancestors}
<HotKeys handlers={handlers}>
<div className={classNames('focusable', 'detailed-status__wrapper')} tabIndex='0' aria-label={textForScreenReader(intl, status, false)}>
<div className={classNames('focusable', 'detailed-status__wrapper', {
'detailed-status__wrapper-referenced': referenced,
'detailed-status__wrapper-context-referenced': contextReferenced,
'detailed-status__wrapper-reference': referenceCount > 0,
})} tabIndex='0' aria-label={textForScreenReader(intl, status, false)}>
<DetailedStatus
key={`details-${status.get('id')}`}
status={status}
referenced={referenced}
contextReferenced={contextReferenced}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
onOpenVideoQuote={this.handleOpenVideoQuote}
@ -628,6 +645,8 @@ class Status extends ImmutablePureComponent {
<ActionBar
key={`action-bar-${status.get('id')}`}
status={status}
referenced={referenced}
contextReferenced={contextReferenced}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick}
@ -649,6 +668,8 @@ class Status extends ImmutablePureComponent {
onEmbed={this.handleEmbed}
addEmojiReaction={this.handleAddEmojiReaction}
removeEmojiReaction={this.handleRemoveEmojiReaction}
onAddReference={this.handleAddReference}
onRemoveReference={this.handleRemoveReference}
/>
</div>
</HotKeys>

View file

@ -0,0 +1,196 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
import Column from '../ui/components/column';
import {
hideStatus,
revealStatus,
} from '../../actions/statuses';
import { makeGetStatus } from '../../selectors';
import ScrollContainer from 'mastodon/containers/scroll_container';
import ColumnBackButton from '../../components/column_back_button';
import ColumnHeader from '../../components/column_header';
import StatusContainer from '../../containers/status_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import Icon from 'mastodon/components/icon';
import DetailedHeaderContaier from '../status/containers/header_container';
const messages = defineMessages({
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getReferencesIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'references']),
], (statusId, contextReference) => {
return ImmutableList(contextReference.get(statusId));
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
const referencesIds = status ? getReferencesIds(state, { id: status.get('id') }) : ImmutableList();
return {
status,
referencesIds,
};
};
return mapStateToProps;
};
export default @injectIntl
@connect(makeMapStateToProps)
class StatusReferences extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
referencesIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
state = {
fullscreen: false,
};
componentWillMount () {
this.props.dispatch(fetchStatus(this.props.params.statusId));
}
componentDidMount () {
attachFullscreenListener(this.onFullScreenChange);
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this._scrolledIntoView = false;
this.props.dispatch(fetchStatus(nextProps.params.statusId));
}
}
handleToggleAll = () => {
const { status, referencesIds } = this.props;
const statusIds = [status.get('id')].concat(referencesIds.toJS());
if (status.get('hidden')) {
this.props.dispatch(revealStatus(statusIds));
} else {
this.props.dispatch(hideStatus(statusIds));
}
}
handleMoveUp = id => {
const { referencesIds } = this.props;
const index = referencesIds.indexOf(id);
if (index > 0) {
this._selectChild(index - 1, true);
}
}
handleMoveDown = id => {
const { referencesIds } = this.props;
const index = referencesIds.indexOf(id);
if (index !== -1 && index + 1 < referencesIds.size)
this._selectChild(index + 1, false);
}
_selectChild (index, align_top) {
const container = this.node;
const element = container.querySelectorAll('.focusable')[index];
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
renderChildren (list) {
return list.map(id => (
<StatusContainer
key={id}
id={id}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType='thread'
/>
));
}
setRef = c => {
this.node = c;
}
componentWillUnmount () {
detachFullscreenListener(this.onFullScreenChange);
}
onFullScreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
}
render () {
const { status, referencesIds, intl, multiColumn } = this.props;
const { fullscreen } = this.state;
if (status === null) {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator />
</Column>
);
}
let references;
if (referencesIds && referencesIds.size > 0) {
references = <div>{this.renderChildren(referencesIds)}</div>;
}
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.detailedStatus)}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
)}
/>
<DetailedHeaderContaier statusId={status.get('id')} />
<ScrollContainer scrollKey='reference'>
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
{references}
</div>
</ScrollContainer>
</Column>
);
}
}

View file

@ -5,12 +5,28 @@ import Audio from 'mastodon/features/audio';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Footer from 'mastodon/features/picture_in_picture/components/footer';
import { addReference, removeReference } from 'mastodon/actions/compose';
import { makeGetStatus } from 'mastodon/selectors';
const mapStateToProps = (state, { statusId }) => ({
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
export default @connect(mapStateToProps)
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.statusId });
const id = status ? getProper(status).get('id') : null;
return {
referenced: state.getIn(['compose', 'references']).has(id),
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', props.statusId, 'account']), 'avatar_static']),
};
};
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
class AudioModal extends ImmutablePureComponent {
static propTypes = {
@ -24,6 +40,14 @@ class AudioModal extends ImmutablePureComponent {
onChangeBackgroundColor: PropTypes.func.isRequired,
};
handleAddReference = (id, change) => {
this.props.dispatch(addReference(id, change));
}
handleRemoveReference = (id) => {
this.props.dispatch(removeReference(id));
}
render () {
const { media, accountStaticAvatar, statusId, onClose } = this.props;
const options = this.props.options || {};

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import IconWithBadge from 'mastodon/components/icon_with_badge';
import { maxReferences } from '../../../initial_state';
const mapStateToProps = state => ({
count: state.getIn(['compose', 'references']).count(),
countMax: maxReferences,
id: 'link',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
export default class StackHeader extends React.PureComponent {
static propTypes = {
title: PropTypes.node,
icon: PropTypes.string,
active: PropTypes.bool,
onClick: PropTypes.func,
extraButton: PropTypes.node,
};
handleClick = () => {
this.props.onClick();
}
render () {
const { icon, title, active, extraButton } = this.props;
let iconElement = '';
if (icon) {
iconElement = <Icon id={icon} fixedWidth className='stack-header__icon' />;
}
return (
<h1 className={classNames('stack-header', { active })}>
<button className='stack-header__button stack-header__button-name' onClick={this.handleClick}>
{iconElement}
{title}
</button>
{extraButton}
</h1>
);
}
}

View file

@ -6,7 +6,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import Footer from 'mastodon/features/picture_in_picture/components/footer';
import { getAverageFromBlurhash } from 'mastodon/blurhash';
export default class VideoModal extends ImmutablePureComponent {
export default
class VideoModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,

View file

@ -26,6 +26,7 @@ import PictureInPicture from 'mastodon/features/picture_in_picture';
import {
Compose,
Status,
StatusReferences,
GettingStarted,
KeyboardShortcuts,
PublicTimeline,
@ -50,6 +51,7 @@ import {
FavouritedStatuses,
BookmarkedStatuses,
EmojiReactionedStatuses,
ReferredByStatuses,
ListTimeline,
Blocks,
DomainBlocks,
@ -188,9 +190,11 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/references' component={StatusReferences} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path='/statuses/:statusId/referred_by' component={ReferredByStatuses} content={children} />
<WrappedRoute path='/statuses/:statusId/mentions' component={Mentions} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />

View file

@ -50,6 +50,10 @@ export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
export function StatusReferences () {
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
}
export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
}
@ -118,6 +122,10 @@ export function EmojiReactionedStatuses () {
return import(/* webpackChunkName: "features/emoji_reactioned_statuses" */'../../emoji_reactioned_statuses');
}
export function ReferredByStatuses () {
return import(/* webpackChunkName: "features/referred_by_statuses" */'../../referred_by_statuses');
}
export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
}
@ -142,6 +150,10 @@ export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}
export function ThumbnailGallery () {
return import(/* webpackChunkName: "status/thumbnail_gallery" */'../../../components/thumbnail_gallery');
}
export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
}

View file

@ -11,6 +11,9 @@ export const unfollowModal = getMeta('unfollow_modal');
export const unsubscribeModal = getMeta('unsubscribe_modal');
export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal');
export const postReferenceModal = getMeta('post_reference_modal');
export const addReferenceModal = getMeta('add_reference_modal');
export const unselectReferenceModal = getMeta('unselect_reference_modal');
export const me = getMeta('me');
export const searchEnabled = getMeta('search_enabled');
export const invitesEnabled = getMeta('invites_enabled');
@ -43,5 +46,8 @@ export const enableReaction = getMeta('enable_reaction');
export const show_reply_tree_button = getMeta('show_reply_tree_button');
export const disable_joke_appearance = getMeta('disable_joke_appearance');
export const new_features_policy = getMeta('new_features_policy');
export const enableStatusReference = getMeta('enable_status_reference');
export const maxReferences = initialState?.status_references?.max_references;
export const matchVisibilityOfReferences = getMeta('match_visibility_of_references');
export default initialState;

View file

@ -146,6 +146,8 @@
"confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.clear.confirm": "Clear all",
"confirmations.clear.message": "Are you sure you want to clear all references?",
"confirmations.delete.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_circle.confirm": "Delete",
@ -159,6 +161,8 @@
"confirmations.mute.confirm": "Mute",
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.post_reference.confirm": "Post",
"confirmations.post_reference.message": "It contains references, do you want to post it?",
"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",
@ -167,6 +171,8 @@
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"confirmations.unselect.confirm": "Unselect",
"confirmations.unselect.message": "Are you sure you want to unselect a reference?",
"confirmations.unsubscribe.confirm": "Unsubscribe",
"confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?",
"conversation.delete": "Delete conversation",
@ -228,6 +234,7 @@
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"empty_column.referred_by_statuses": "There are no referred by posts yet. When someone refers a post, it will appear here.",
"empty_column.suggestions": "No one has suggestions yet.",
"empty_column.trends": "No one has trends yet.",
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
@ -402,6 +409,7 @@
"notification.emoji_reaction": "{name} reactioned your post",
"notification.reblog": "{name} boosted your post",
"notification.status": "{name} just posted",
"notification.status_reference": "{name} referenced your post",
"notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications",
@ -419,6 +427,7 @@
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
"notifications.column_settings.status": "New posts:",
"notifications.column_settings.status_reference": "Status reference:",
"notifications.column_settings.unread_markers.category": "Unread notification markers",
"notifications.filter.all": "All",
"notifications.filter.boosts": "Boosts",
@ -428,6 +437,7 @@
"notifications.filter.polls": "Poll results",
"notifications.filter.emoji_reactions": "Reactions",
"notifications.filter.statuses": "Updates from people you follow",
"notifications.filter.status_references": "Status references",
"notifications.grant_permission": "Grant permission.",
"notifications.group": "{count} notifications",
"notifications.mark_as_read": "Mark every notification as read",
@ -460,6 +470,8 @@
"privacy.unlisted.long": "Visible for all, but not in public timelines",
"privacy.unlisted.short": "Unlisted",
"quote_indicator.cancel": "Cancel",
"reference_stack.header": "References",
"reference_stack.unselect": "Unselecting a post",
"refresh": "Refresh",
"regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
@ -493,6 +505,7 @@
"status.block": "Block @{name}",
"status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost",
"status.cancel_reference": "Remove reference",
"status.cannot_quote": "This post cannot be quoted",
"status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to post",
@ -523,6 +536,8 @@
"status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft",
"status.reference": "Reference",
"status.referred_by": "Referred",
"status.remove_bookmark": "Remove bookmark",
"status.reply": "Reply",
"status.replyAll": "Reply to thread",
@ -538,7 +553,9 @@
"status.show_more_all": "Show more for all",
"status.show_poll": "Show poll",
"status.show_reblogs": "Show boosted users",
"status.show_referred_by_statuses": "Show referred by statuses",
"status.show_thread": "Show thread",
"status.thread_with_references": "Thread",
"status.uncached_media_warning": "Not available",
"status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation",
@ -553,6 +570,12 @@
"tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications",
"tabs_bar.search": "Search",
"thread_mark.ancestor": "Has reference",
"thread_mark.both": "Has reference and reply",
"thread_mark.descendant": "Has reply",
"thumbnail.type.audio": "(Audio)",
"thumbnail.type.gif": "(GIF)",
"thumbnail.type.video": "(Video)",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
@ -598,5 +621,9 @@
"video.mute": "Mute sound",
"video.pause": "Pause",
"video.play": "Play",
"video.unmute": "Unmute sound"
"video.unmute": "Unmute sound",
"visibility.match_message": "Do you want to match the visibility of the post to the reference?",
"visibility.keep_message": "Do you want to keep the visibility of the post to the reference?",
"visibility.change": "Change",
"visibility.keep": "Keep"
}

View file

@ -146,6 +146,8 @@
"confirmations.block.block_and_report": "ブロックし通報",
"confirmations.block.confirm": "ブロック",
"confirmations.block.message": "本当に{name}さんをブロックしますか?",
"confirmations.clear.confirm": "すべての参照を解除",
"confirmations.clear.message": "本当にすべての参照を解除しますか?",
"confirmations.delete.confirm": "削除",
"confirmations.delete.message": "本当に削除しますか?",
"confirmations.delete_circle.confirm": "削除",
@ -159,6 +161,8 @@
"confirmations.mute.confirm": "ミュート",
"confirmations.mute.explanation": "これにより相手の投稿と返信は見えなくなりますが、相手はあなたをフォローし続け投稿を見ることができます。",
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
"confirmations.post_reference.confirm": "投稿",
"confirmations.post_reference.message": "参照を含んでいますが、投稿しますか?",
"confirmations.quote.confirm": "引用",
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.redraft.confirm": "削除して下書きに戻す",
@ -167,6 +171,8 @@
"confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.unfollow.confirm": "フォロー解除",
"confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?",
"confirmations.unselect.confirm": "選択解除",
"confirmations.unselect.message": "本当に参照の選択を解除しますか?",
"confirmations.unsubscribe.confirm": "購読解除",
"confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?",
"conversation.delete": "会話を削除",
@ -222,12 +228,12 @@
"empty_column.hashtag": "このハッシュタグはまだ使われていません。",
"empty_column.home": "ホームタイムラインはまだ空っぽです。誰かフォローして埋めてみましょう。 {suggestions}",
"empty_column.home.suggestions": "おすすめを見る",
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
"empty_column.limited": "まだ誰からも公開範囲が限定された投稿を受け取っていません。",
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
"empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。",
"empty_column.mutes": "まだ誰もミュートしていません。",
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
"empty_column.referred_by_statuses": "まだ、参照している投稿はありません。誰かが投稿を参照すると、ここに表示されます。",
"empty_column.suggestions": "まだおすすめできるユーザーがいません。",
"empty_column.trends": "まだ何もトレンドがありません。",
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう",
@ -403,6 +409,7 @@
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.status": "{name}さんが投稿しました",
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
"notifications.clear": "通知を消去",
"notifications.clear_confirmation": "本当に通知を消去しますか?",
"notifications.column_settings.alert": "デスクトップ通知",
@ -420,6 +427,7 @@
"notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.sound": "通知音を再生",
"notifications.column_settings.status": "新しい投稿:",
"notifications.column_settings.status_reference": "投稿の参照:",
"notifications.column_settings.unread_markers.category": "未読マーカー",
"notifications.filter.all": "すべて",
"notifications.filter.boosts": "ブースト",
@ -429,6 +437,7 @@
"notifications.filter.polls": "アンケート結果",
"notifications.filter.emoji_reactions": "リアクション",
"notifications.filter.statuses": "フォローしている人の新着情報",
"notifications.filter.status_references": "投稿の参照",
"notifications.grant_permission": "権限の付与",
"notifications.group": "{count} 件の通知",
"notifications.mark_as_read": "すべて既読にする",
@ -461,6 +470,8 @@
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
"privacy.unlisted.short": "未収載",
"quote_indicator.cancel": "キャンセル",
"reference_stack.header": "参照",
"reference_stack.unselect": "投稿を選択解除",
"refresh": "更新",
"regeneration_indicator.label": "読み込み中…",
"regeneration_indicator.sublabel": "ホームタイムラインは準備中です!",
@ -494,6 +505,7 @@
"status.block": "@{name}さんをブロック",
"status.bookmark": "ブックマーク",
"status.cancel_reblog_private": "ブースト解除",
"status.cancel_reference": "参照解除",
"status.cannot_quote": "この投稿は引用できません",
"status.cannot_reblog": "この投稿はブーストできません",
"status.copy": "投稿へのリンクをコピー",
@ -524,6 +536,8 @@
"status.reblogged_by": "{name}さんがブースト",
"status.reblogs.empty": "まだ誰もブーストしていません。ブーストされるとここに表示されます。",
"status.redraft": "削除して下書きに戻す",
"status.reference": "参照",
"status.referred_by": "参照",
"status.remove_bookmark": "ブックマークを削除",
"status.reply": "返信",
"status.replyAll": "全員に返信",
@ -539,7 +553,9 @@
"status.show_more_all": "全て見る",
"status.show_poll": "アンケートを表示",
"status.show_reblogs": "ブーストしたユーザーを表示",
"status.show_referred_by_statuses": "参照している投稿を表示",
"status.show_thread": "スレッドを表示",
"status.thread_with_references": "スレッド",
"status.uncached_media_warning": "利用できません",
"status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除",
@ -554,6 +570,12 @@
"tabs_bar.local_timeline": "ローカル",
"tabs_bar.notifications": "通知",
"tabs_bar.search": "検索",
"thread_mark.ancestor": "参照あり",
"thread_mark.both": "参照・返信あり",
"thread_mark.descendant": "返信あり",
"thumbnail.type.audio": "(音声)",
"thumbnail.type.gif": "GIF",
"thumbnail.type.video": "(動画)",
"time_remaining.days": "残り{number}日",
"time_remaining.hours": "残り{number}時間",
"time_remaining.minutes": "残り{number}分",
@ -599,5 +621,9 @@
"video.mute": "ミュート",
"video.pause": "一時停止",
"video.play": "再生",
"video.unmute": "ミュートを解除する"
"video.unmute": "ミュートを解除する",
"visibility.match_message": "投稿の公開範囲を参照先に合わせますか?",
"visibility.keep_message": "投稿の公開範囲を参照先に合わせず維持しますか?",
"visibility.change": "変更",
"visibility.keep": "維持"
}

View file

@ -50,11 +50,14 @@ import {
COMPOSE_SCHEDULED_CHANGE,
COMPOSE_EXPIRES_CHANGE,
COMPOSE_EXPIRES_ACTION_CHANGE,
COMPOSE_REFERENCE_ADD,
COMPOSE_REFERENCE_REMOVE,
COMPOSE_REFERENCE_RESET,
} from '../actions/compose';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store';
import { REDRAFT } from '../actions/statuses';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import uuid from '../uuid';
import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html';
@ -103,6 +106,8 @@ const initialState = ImmutableMap({
scheduled: null,
expires: null,
expires_action: 'mark',
references: ImmutableSet(),
context_references: ImmutableSet(),
});
const initialPoll = ImmutableMap({
@ -155,6 +160,8 @@ const clearAll = state => {
map.set('scheduled', null);
map.set('expires', null);
map.set('expires_action', 'mark');
map.update('references', set => set.clear());
map.update('context_references', set => set.clear());
});
};
@ -374,6 +381,7 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null);
map.set('expires', null);
map.set('expires_action', 'mark');
map.update('context_references', set => set.clear().concat(action.context_references));
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
@ -397,6 +405,7 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null);
map.set('expires', null);
map.set('expires_action', 'mark');
map.update('context_references', set => set.clear().add(action.status.get('id')));
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
@ -425,6 +434,10 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null);
map.set('expires', null);
map.set('expires_action', 'mark');
map.update('context_references', set => set.clear());
if (action.type == COMPOSE_RESET) {
map.update('references', set => set.clear());
}
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
@ -539,6 +552,8 @@ export default function compose(state = initialState, action) {
map.set('scheduled', action.status.get('scheduled_at'));
map.set('expires', action.status.get('expires_at') ? format(parseISO(action.status.get('expires_at')), 'yyyy-MM-dd HH:mm') : null);
map.set('expires_action', action.status.get('expires_action') ?? 'mark');
map.update('references', set => set.clear().concat(action.status.get('status_reference_ids')));
map.update('context_references', set => set.clear().concat(action.context_references));
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
@ -583,6 +598,12 @@ export default function compose(state = initialState, action) {
return state.set('expires', action.value);
case COMPOSE_EXPIRES_ACTION_CHANGE:
return state.set('expires_action', action.value);
case COMPOSE_REFERENCE_ADD:
return state.update('references', set => set.add(action.id));
case COMPOSE_REFERENCE_REMOVE:
return state.update('references', set => set.delete(action.id));
case COMPOSE_REFERENCE_RESET:
return state.update('references', set => set.clear());
default:
return state;
}

View file

@ -10,33 +10,45 @@ import compareId from '../compare_id';
const initialState = ImmutableMap({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
references: ImmutableMap(),
});
const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
const normalizeContext = (immutableState, id, ancestors, descendants, references) => immutableState.withMutations(state => {
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id && !inReplyTos.has(id)) {
state.update('references', immutableReferences => immutableReferences.withMutations(refs => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id && !inReplyTos.has(id)) {
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, id);
});
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, id);
});
inReplyTos.set(id, in_reply_to_id);
inReplyTos.set(id, in_reply_to_id);
}
}
}
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
ancestors.forEach(addReply);
ancestors.forEach(addReply);
if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
descendants.forEach(addReply);
descendants.forEach(addReply);
if (references.length > 0) {
const referencesIds = ImmutableList();
refs.set(id, referencesIds.withMutations(refIds => {
references.forEach(reference => {
refIds.push(reference.id);
});
}));
}
}));
}));
}));
});
@ -44,23 +56,26 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab
const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
ids.forEach(id => {
const inReplyToIdOfId = inReplyTos.get(id);
const repliesOfId = replies.get(id);
const siblings = replies.get(inReplyToIdOfId);
state.update('references', immutableReferences => immutableReferences.withMutations(refs => {
ids.forEach(id => {
const inReplyToIdOfId = inReplyTos.get(id);
const repliesOfId = replies.get(id);
const siblings = replies.get(inReplyToIdOfId);
if (siblings) {
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
}
if (siblings) {
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
}
if (repliesOfId) {
repliesOfId.forEach(reply => inReplyTos.delete(reply));
}
if (repliesOfId) {
repliesOfId.forEach(reply => inReplyTos.delete(reply));
}
inReplyTos.delete(id);
replies.delete(id);
});
inReplyTos.delete(id);
replies.delete(id);
refs.delete(id);
});
}));
}));
}));
});
@ -95,7 +110,7 @@ export default function replies(state = initialState, action) {
case ACCOUNT_MUTE_SUCCESS:
return filterContexts(state, action.relationship, action.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
return normalizeContext(state, action.id, action.ancestors, action.descendants, action.references);
case TIMELINE_DELETE:
case TIMELINE_EXPIRE:
return deleteFromContexts(state, [action.id]);

View file

@ -7,6 +7,7 @@ import { loadingBarReducer } from 'react-redux-loading-bar';
import modal from './modal';
import user_lists from './user_lists';
import domain_lists from './domain_lists';
import status_status_lists from './status_status_lists';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import statuses from './statuses';
@ -55,6 +56,7 @@ const reducers = {
user_lists,
domain_lists,
status_lists,
status_status_lists,
accounts,
accounts_counters,
statuses,

View file

@ -54,6 +54,7 @@ const initialState = ImmutableMap({
poll: false,
status: false,
emoji_reaction: false,
status_reference: false,
}),
quickFilter: ImmutableMap({
@ -74,6 +75,7 @@ const initialState = ImmutableMap({
poll: true,
status: true,
emoji_reaction: true,
status_reference: false,
}),
sounds: ImmutableMap({
@ -85,6 +87,7 @@ const initialState = ImmutableMap({
poll: true,
status: true,
emoji_reaction: true,
status_reference: false,
}),
}),

View file

@ -37,6 +37,12 @@ import {
UNPIN_SUCCESS,
} from '../actions/interactions';
const initialListState = ImmutableMap({
next: null,
isLoading: false,
items: ImmutableList(),
});
const initialState = ImmutableMap({
favourites: ImmutableMap({
next: null,

View file

@ -0,0 +1,44 @@
import {
REFERRED_BY_STATUSES_FETCH_REQUEST,
REFERRED_BY_STATUSES_FETCH_SUCCESS,
REFERRED_BY_STATUSES_FETCH_FAIL,
REFERRED_BY_STATUSES_EXPAND_REQUEST,
REFERRED_BY_STATUSES_EXPAND_SUCCESS,
REFERRED_BY_STATUSES_EXPAND_FAIL,
} from '../actions/interactions';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
const initialState = ImmutableMap({
referred_by: ImmutableMap(),
});
const normalizeList = (state, path, statuses, next) => {
return state.setIn(path, ImmutableMap({
next,
items: ImmutableList(statuses.map(item => item.id)),
isLoading: false,
}));
};
const appendToList = (state, path, statuses, next) => {
return state.updateIn(path, map => {
return map.set('next', next).set('isLoading', false).update('items', list => list.concat(statuses.map(item => item.id)));
});
};
export default function userLists(state = initialState, action) {
switch(action.type) {
case REFERRED_BY_STATUSES_FETCH_SUCCESS:
return normalizeList(state, ['referred_by', action.id], action.statuses, action.next);
case REFERRED_BY_STATUSES_EXPAND_SUCCESS:
return appendToList(state, ['referred_by', action.id], action.statuses, action.next);
case REFERRED_BY_STATUSES_FETCH_REQUEST:
case REFERRED_BY_STATUSES_EXPAND_REQUEST:
return state.setIn(['referred_by', action.id, 'isLoading'], true);
case REFERRED_BY_STATUSES_FETCH_FAIL:
case REFERRED_BY_STATUSES_EXPAND_FAIL:
return state.setIn(['referred_by', action.id, 'isLoading'], false);
default:
return state;
}
};

View file

@ -408,7 +408,8 @@ html {
border-top: 0;
}
.icon-with-badge__badge {
.icon-with-badge__badge,
.status__thread_mark {
border-color: $white;
}
@ -748,7 +749,8 @@ html {
}
.public-layout {
.account__section-headline {
.account__section-headline,
.status__section-headline {
border: 1px solid lighten($ui-base-color, 8%);
@media screen and (max-width: $no-gap-breakpoint) {
@ -837,7 +839,8 @@ html {
}
.notification__filter-bar button.active::after,
.account__section-headline a.active::after {
.account__section-headline a.active::after,
.status__section-headline a.active::after {
border-color: transparent transparent $white;
}

View file

@ -55,7 +55,8 @@
}
}
.account__section-headline {
.account__section-headline,
.status__section-headline {
@include shadow-1dp;
border-radius: $card-radius $card-radius 0 0;
}

View file

@ -983,7 +983,7 @@
.status__content__read-more-button {
display: block;
font-size: 15px;
font-size: 12px;
line-height: 20px;
color: lighten($ui-highlight-color, 8%);
border: 0;
@ -1184,6 +1184,7 @@
.status__relative-time,
.status__visibility-icon,
.status__thread_mark,
.status__expiration-time,
.notification__relative_time {
color: $dark-text-color;
@ -1213,6 +1214,27 @@
color: $dark-text-color;
}
.status__thread_mark {
background: $ui-highlight-color;
border: 2px solid lighten($ui-base-color, 8%);
padding: 0px 4px;
border-radius: 6px;
font-size: 8px;
font-weight: 500;
line-height: 14px;
color: $primary-text-color;
margin-inline-start: 2px;
&.status__thread_mark-both,
&.status__thread_mark-ancenstor.status__thread_mark-descendant {
background: $active-passive-text-color;
}
&.status__thread_mark-descendant {
background: $passive-text-color;
}
}
.status__info .status__display-name {
display: block;
max-width: 100%;
@ -1327,6 +1349,7 @@
.detailed-status {
background: lighten($ui-base-color, 4%);
padding: 14px 10px;
position: relative;
&--flex {
display: flex;
@ -1360,6 +1383,28 @@
.audio-player {
margin-top: 8px;
}
&.referenced,
&.context-referenced {
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
pointer-events: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
}
&.context-referenced::before {
border-left: 4px solid darken($passive-text-color, 20%);
}
&.referenced::before {
border-left: 4px solid $passive-text-color;
}
}
.detailed-status__meta {
@ -1385,6 +1430,7 @@
.detailed-status__favorites,
.detailed-status__emoji_reactions,
.detailed-status__status_referred_by,
.detailed-status__reblogs {
display: inline-block;
font-weight: 500;
@ -2868,7 +2914,7 @@ a.account__display-name {
display: flex;
flex-direction: column;
height: calc(100% - 10px);
overflow-y: hidden;
overflow-y: auto;
.navigation-bar {
padding-top: 20px;
@ -2882,7 +2928,7 @@ a.account__display-name {
}
.compose-form {
flex: 1;
flex: 1 0 auto;
overflow-y: hidden;
display: flex;
flex-direction: column;
@ -6545,8 +6591,16 @@ a.status-card.compact:hover {
}
}
.status__section-headline + .activity-stream {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.notification__filter-bar,
.account__section-headline,
.status__section-headline,
.detailed-status__section-headline,
.status-reactioned__section-headline {
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
@ -7931,7 +7985,8 @@ noscript {
}
.notification,
.status__wrapper {
.status__wrapper,
.mini-status__wrapper {
position: relative;
&.unread {
@ -7947,6 +8002,29 @@ noscript {
pointer-events: none;
}
}
&.status__wrapper-referenced,
&.status__wrapper-context-referenced {
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
pointer-events: 0;
width: 100%;
height: 100%;
border-left: 4px solid $highlight-text-color;
pointer-events: none;
}
}
&.status__wrapper-context-referenced::before {
border-left: 4px solid darken($passive-text-color, 20%);
}
&.status__wrapper-referenced::before {
border-left: 4px solid $passive-text-color;
}
}
.picture-in-picture {
@ -8114,3 +8192,188 @@ noscript {
}
}
.reference-link-inline {
display: none;
font-size: 80%;
}
.public-layout,
.status__wrapper-reference,
.detailed-status__wrapper-reference {
.reference-link-inline {
display: inline;
}
}
.reference-stack {
margin: 10px 0;
.stack-header {
display: flex;
font-size: 16px;
flex: 0 0 auto;
cursor: pointer;
position: relative;
outline: 0;
min-height: 36px;
& :first-child {
border-start-start-radius: 4px;
}
& :last-child {
border-start-end-radius: 4px;
}
&__button {
flex: 0 0 auto;
border: 0;
margin: 0;
color: inherit;
background: lighten($ui-base-color, 4%);
cursor: pointer;
font-size: 16px;
padding: 0 15px;
&:hover {
color: lighten($darker-text-color, 7%);
}
&.active {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
&:hover {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
}
}
&-name {
flex: 1 1 auto;
text-align: start;
.icon-with-badge {
margin-right: 20px;
}
&:hover {
color: inherit;
background: lighten($ui-base-color, 4%);
}
}
}
}
.reference-stack__list {
background: $ui-base-color;
}
}
.mini-status {
display: flex;
gap: 10px;
padding: 8px 10px;
position: relative;
border-bottom: 1px solid lighten($ui-base-color, 8%);
cursor: auto;
&__account {
flex: 0 0 auto;
}
&__content {
flex: 1 1 auto;
&__text {
overflow-y: hidden;
max-height: 2.5em;
word-break: break-all;
}
}
&__unselect {
flex: 0 0 auto;
line-height: 24px;
}
}
.thumbnail-gallery {
display: flex;
gap: 3px;
min-height: auto;
box-sizing: border-box;
margin-top: 8px;
overflow: hidden;
position: relative;
z-index: 0;
width: 100%;
}
.thumbnail-gallery__item {
flex: 0 0 auto;
border: 0;
box-sizing: border-box;
display: block;
float: left;
position: relative;
border-radius: 4px;
overflow: hidden;
width: 36px;
height: 20px;
&-thumbnail {
display: block;
text-decoration: none;
color: $secondary-text-color;
position: relative;
z-index: 1;
object-fit: cover;
font-family: 'object-fit: cover;';
width: 36px;
height: 20px;
border-radius: 2px;
}
}
.thumbnail-gallery__type {
flex: 1 0 auto;
height: 20px;
color: $darker-text-color;
}
.thumbnail-gallery__preview {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
z-index: 0;
background: $base-overlay-background;
&--hidden {
display: none;
}
}
.thumbnail-gallery__gifv {
width: 36px;
height: 20px;
overflow: hidden;
position: relative;
}
.thumbnail-gallery__item-gifv-thumbnail {
width: 36px;
height: 20px;
object-fit: cover;
position: relative;
top: 50%;
transform: translateY(-50%);
z-index: 1;
pointer-events: none;
}

View file

@ -773,7 +773,8 @@
}
}
.account__section-headline {
.account__section-headline,
.status__section-headline {
border-radius: 4px 4px 0 0;
@media screen and (max-width: $no-gap-breakpoint) {

View file

@ -270,6 +270,7 @@ body.rtl {
.detailed-status__favorites,
.detailed-status__emoji_reactions,
.detailed-status__status_referred_by,
.detailed-status__reblogs {
margin-left: 0;
margin-right: 6px;

View file

@ -84,6 +84,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
attach_tags(@status)
end
resolve_references(@status, @mentions, @object['references'])
resolve_thread(@status)
fetch_replies(@status)
distribute(@status)
@ -243,6 +244,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
Rails.logger.warn "Error storing emoji: #{e}"
end
def resolve_references(status, mentions, collection)
references = []
references = ActivityPub::FetchReferencesService.new.call(status, collection) unless collection.nil?
ProcessStatusReferenceService.new.call(status, mentions: mentions, urls: (references + [quote_uri]).compact.uniq)
end
def process_attachments
return [] if @object['attachment'].nil?
@ -553,6 +560,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
retry
end
def quote_uri
ActivityPub::TagManager.instance.uri_for(quote) if quote
end
def quote
@quote ||= quote_from_url(@object['quoteUri'] || @object['_misskey_quote'])
end

View file

@ -72,6 +72,12 @@ class ActivityPub::TagManager
account_status_replies_url(target.account, target, page_params)
end
def references_uri_for(target, page_params = nil)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
account_status_references_url(target.account, target, page_params)
end
# Primary audience of a status
# Public statuses go out to primarily the public collection
# Unlisted and private statuses go out primarily to the followers collection

View file

@ -59,7 +59,7 @@ class EntityCache
account
end
def holding_status_and_account(url)
def holding_status(url)
return Rails.cache.read(to_key(:holding_status, url)) if Rails.cache.exist?(to_key(:holding_status, url))
status = begin
@ -72,11 +72,13 @@ class EntityCache
nil
end
account = status&.account
update_holding_status(url, status)
Rails.cache.write(to_key(:holding_status, url), [status, account], expires_in: account.nil? ? MIN_EXPIRATION : MAX_EXPIRATION)
status
end
[status, account]
def update_holding_status(url, status)
Rails.cache.write(to_key(:holding_status, url), status, expires_in: status&.account.nil? ? MIN_EXPIRATION : MAX_EXPIRATION)
end
def to_key(type, *ids)

View file

@ -50,6 +50,8 @@ class FeedManager
filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
when :mentions
filter_from_mentions?(status, receiver.id)
when :status_references
filter_from_status_references?(status, receiver.id)
else
false
end
@ -425,6 +427,28 @@ class FeedManager
should_filter
end
# Check if status should not be added to the status reference feed
# @see NotifyService
# @param [Status] status
# @param [Integer] receiver_id
# @return [Boolean]
def filter_from_status_references?(status, receiver_id)
return true if receiver_id == status.account_id
return true if phrase_filtered?(status, receiver_id, :notifications)
return true unless StatusPolicy.new(Account.find(receiver_id), status).subscribe?
# This filter is called from NotifyService, but already after the sender of
# the notification has been checked for mute/block. Therefore, it's not
# necessary to check the author of the toot for mute/block again
check_for_blocks = status.active_mentions.pluck(:account_id)
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :status_references) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
should_filter
end
# Check if status should not be added to the list feed
# @param [Status] status
# @param [List] list

View file

@ -27,6 +27,7 @@ class Formatter
unless status.local?
html = reformat(raw_content)
html = apply_inner_link(html)
html = apply_reference_link(html, status)
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = nyaize_html(html) if options[:nyaize]
return html.html_safe # rubocop:disable Rails/OutputSafety
@ -41,6 +42,7 @@ class Formatter
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = simple_format(html, {}, sanitize: false)
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
html = add_compatible_reference_link(html, status) if status.references.exists?
html = nyaize_html(html) if options[:nyaize]
html = html.delete("\n")
@ -68,6 +70,7 @@ class Formatter
return status.text if status.local?
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
text = remove_reference_link(text)
strip_tags(text)
end
@ -212,6 +215,12 @@ class Formatter
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{link}</span>\\1")
end
def add_compatible_reference_link(html, status)
url = references_short_account_status_url(status.account, status)
link = "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"status-link unhandled-link\" data-status-id=\"#{status.id}\">#{I18n.t('status_references.link_text')}</a>"
html.sub(/<\/p>\z/, "<span class=\"reference-link-inline\"> #{link}</span></p>")
end
def nyaize_html(html)
inside_anchor = false
@ -293,8 +302,9 @@ class Formatter
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
status, account = url_to_holding_status_and_account(url.normalize.to_s)
account = url_to_holding_account(url.normalize.to_s) if status.nil?
status = url_to_holding_status(url.normalize.to_s)
account = status&.account
account = url_to_holding_account(url.normalize.to_s) if status.nil?
if status.present? && account.present?
html_attrs[:class] = class_append(html_attrs[:class], ['status-url-link'])
@ -315,8 +325,9 @@ class Formatter
def apply_inner_link(html)
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
doc.css('a').map do |x|
status, account = url_to_holding_status_and_account(x['href'])
account = url_to_holding_account(x['href']) if status.nil?
status = url_to_holding_status(x['href'])
account = status&.account
account = url_to_holding_account(x['href']) if status.nil?
if status.present? && account.present?
x.add_class('status-url-link')
@ -333,6 +344,44 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
def remove_reference_link(html)
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
doc.at_css('span.reference-link-inline')&.unlink
html = doc.at_css('body')&.inner_html || ''
html.html_safe # rubocop:disable Rails/OutputSafety
end
def apply_reference_link(html, status)
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
reference_link_url = nil
doc.at_css('span.reference-link-inline').tap do |x|
if x.present?
reference_link_url = x.at_css('a')&.attr('href')
x.unlink
end
end
if status.references.exists?
ref_span = Nokogiri::XML::Node.new("span", doc)
ref_anchor = Nokogiri::XML::Node.new("a", doc)
ref_anchor.add_class('status-link unhandled-link')
ref_anchor['href'] = reference_link_url || status.url
ref_anchor['target'] = '_blank'
ref_anchor['rel'] = 'noopener noreferrer'
ref_anchor['data-status-id'] = status.id
ref_anchor.content = I18n.t('status_references.link_text')
ref_span.content = ' '
ref_span.add_class('reference-link-inline')
ref_span.add_child(ref_anchor)
(doc.at_css('body > p:last-child') || doc.at_css('body'))&.add_child(ref_span)
end
html = doc.at_css('body')&.inner_html || ''
html.html_safe # rubocop:disable Rails/OutputSafety
end
def url_to_holding_account(url)
url = url.split('#').first
@ -341,12 +390,12 @@ class Formatter
EntityCache.instance.holding_account(url)
end
def url_to_holding_status_and_account(url)
def url_to_holding_status(url)
url = url.split('#').first
return if url.nil?
EntityCache.instance.holding_status_and_account(url)
EntityCache.instance.holding_status(url)
end
def link_to_mention(entity, linkable_accounts, options = {})

View file

@ -21,6 +21,8 @@ class InlineRenderer
serializer = REST::ReactionSerializer
when :emoji_reaction
serializer = REST::GroupedEmojiReactionSerializer
when :status_reference
serializer = REST::StatusReferenceSerializer
when :encrypted_message
serializer = REST::EncryptedMessageSerializer
else

View file

@ -38,6 +38,9 @@ class UserSettingsDecorator
user.settings['unsubscribe_modal'] = unsubscribe_modal_preference if change?('setting_unsubscribe_modal')
user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal')
user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal')
user.settings['post_reference_modal'] = post_reference_modal_preference if change?('setting_post_reference_modal')
user.settings['add_reference_modal'] = add_reference_modal_preference if change?('setting_add_reference_modal')
user.settings['unselect_reference_modal'] = unselect_reference_modal_preference if change?('setting_unselect_reference_modal')
user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif')
user.settings['display_media'] = display_media_preference if change?('setting_display_media')
user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers')
@ -73,7 +76,9 @@ class UserSettingsDecorator
user.settings['disable_joke_appearance'] = disable_joke_appearance_preference if change?('setting_disable_joke_appearance')
user.settings['new_features_policy'] = new_features_policy if change?('setting_new_features_policy')
user.settings['theme_instance_ticker'] = theme_instance_ticker if change?('setting_theme_instance_ticker')
end
user.settings['enable_status_reference'] = enable_status_reference_preference if change?('setting_enable_status_reference')
user.settings['match_visibility_of_references'] = match_visibility_of_references_preference if change?('setting_match_visibility_of_references')
end
def merged_notification_emails
user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
@ -107,6 +112,18 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_delete_modal'
end
def post_reference_modal_preference
boolean_cast_setting 'setting_post_reference_modal'
end
def add_reference_modal_preference
boolean_cast_setting 'setting_add_reference_modal'
end
def unselect_reference_modal_preference
boolean_cast_setting 'setting_unselect_reference_modal'
end
def system_font_ui_preference
boolean_cast_setting 'setting_system_font_ui'
end
@ -251,6 +268,14 @@ class UserSettingsDecorator
settings['setting_theme_instance_ticker']
end
def enable_status_reference_preference
boolean_cast_setting 'setting_enable_status_reference'
end
def match_visibility_of_references_preference
boolean_cast_setting 'setting_match_visibility_of_references'
end
def boolean_cast_setting(key)
ActiveModel::Type::Boolean.new.cast(settings[key])
end

View file

@ -80,6 +80,19 @@ class NotificationMailer < ApplicationMailer
end
end
def status_reference(recipient, notification)
@me = recipient
@account = notification.from_account
@status = notification.target_status
return unless @me.user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail to: @me.user.email, subject: I18n.t('notification_mailer.status_reference.subject', name: @account.acct)
end
end
def digest(recipient, **opts)
return unless recipient.user.functional?

View file

@ -38,7 +38,7 @@ module AccountSettings
end
def noindex?
true & (local? ? user&.noindex? : settings['noindex'])
true & (local? ? user&.noindex? : (settings['noindex'].nil? ? true : settings['noindex']))
end
def hide_network?

View file

@ -4,27 +4,51 @@ module StatusThreadingConcern
extend ActiveSupport::Concern
def ancestors(limit, account = nil)
find_statuses_from_tree_path(ancestor_ids(limit), account)
find_statuses_from_tree_path(ancestor_ids(limit, account), account)
end
def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account, promote: true)
end
def thread_references(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
find_statuses_from_tree_path(references_ids(limit, account, max_child_id, since_child_id, depth), account)
end
def self_replies(limit)
account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit)
end
private
def ancestor_ids(limit)
def ancestor_ids(limit, account)
ancestor_ids_account_ids(limit, account).map(&:first).reverse!
end
def descendant_ids(limit, max_child_id, since_child_id, depth)
descendant_ids_account_ids(limit, max_child_id, since_child_id, depth).map(&:first)
end
def references_ids(limit, account, max_child_id, since_child_id, depth)
ancestors = ancestor_ids_account_ids(limit, account)
descendants = descendant_ids_account_ids(limit, max_child_id, since_child_id, depth)
self_reply_ids = []
self_reply_ids += ancestors .take_while { |id, status_account_id| status_account_id == account_id }.map(&:first)
self_reply_ids += descendants.take_while { |id, status_account_id| status_account_id == account_id }.map(&:first)
reference_ids = StatusReference.where(status_id: [id] + self_reply_ids).pluck(:target_status_id)
reference_ids -= ancestors.map(&:first) + descendants.map(&:first)
reference_ids.sort!.reverse!
end
def ancestor_ids_account_ids(limit, account)
key = "ancestors:#{id}"
ancestors = Rails.cache.fetch(key)
if ancestors.nil? || ancestors[:limit] < limit
ids = ancestor_statuses(limit).pluck(:id).reverse!
Rails.cache.write key, limit: limit, ids: ids
ids
ancestor_statuses(limit).pluck(:id, :account_id).tap do |ids_account_ids|
Rails.cache.write key, limit: limit, ids: ids_account_ids
end
else
ancestors[:ids].last(limit)
end
@ -32,26 +56,26 @@ module StatusThreadingConcern
def ancestor_statuses(limit)
Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit])
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
WITH RECURSIVE search_tree(id, account_id, in_reply_to_id, path)
AS (
SELECT id, in_reply_to_id, ARRAY[id]
SELECT id, account_id, in_reply_to_id, ARRAY[id]
FROM statuses
WHERE id = :id
UNION ALL
SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id
SELECT statuses.id, statuses.account_id, statuses.in_reply_to_id, path || statuses.id
FROM search_tree
JOIN statuses ON statuses.id = search_tree.in_reply_to_id
WHERE NOT statuses.id = ANY(path)
)
SELECT id
SELECT id, account_id
FROM search_tree
ORDER BY path
LIMIT :limit
SQL
end
def descendant_ids(limit, max_child_id, since_child_id, depth)
descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id)
def descendant_ids_account_ids(limit, max_child_id, since_child_id, depth)
@descendant_statuses ||= descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id, :account_id)
end
def descendant_statuses(limit, max_child_id, since_child_id, depth)
@ -60,18 +84,18 @@ module StatusThreadingConcern
limit += 1 if limit.present?
descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
WITH RECURSIVE search_tree(id, path)
WITH RECURSIVE search_tree(id, account_id, path)
AS (
SELECT id, ARRAY[id]
SELECT id, account_id, ARRAY[id]
FROM statuses
WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
UNION ALL
SELECT statuses.id, path || statuses.id
SELECT statuses.id, statuses.account_id, path || statuses.id
FROM search_tree
JOIN statuses ON statuses.in_reply_to_id = search_tree.id
WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
)
SELECT id
SELECT id, account_id
FROM search_tree
ORDER BY path
LIMIT :limit
@ -81,12 +105,7 @@ module StatusThreadingConcern
end
def find_statuses_from_tree_path(ids, account, promote: false)
statuses = Status.with_accounts(ids).to_a
account_ids = statuses.map(&:account_id).uniq
account_relations = relations_map_for_account(account, account_ids)
status_relations = relations_map_for_status(account, statuses)
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
statuses = Status.permitted_statuses_from_ids(ids, account)
# Order ancestors/descendants by tree path
statuses.sort_by! { |status| ids.index(status.id) }
@ -113,32 +132,4 @@ module StatusThreadingConcern
arr
end
def relations_map_for_account(account, account_ids)
return {} if account.nil?
presenter = AccountRelationshipsPresenter.new(account_ids, account)
{
blocking: presenter.blocking,
blocked_by: presenter.blocked_by,
muting: presenter.muting,
following: presenter.following,
subscribing: presenter.subscribing,
domain_blocking_by_domain: presenter.domain_blocking,
}
end
def relations_map_for_status(account, statuses)
return {} if account.nil?
presenter = StatusRelationshipsPresenter.new(statuses, account)
{
reblogs_map: presenter.reblogs_map,
favourites_map: presenter.favourites_map,
bookmarks_map: presenter.bookmarks_map,
emoji_reactions_map: presenter.emoji_reactions_map,
mutes_map: presenter.mutes_map,
pins_map: presenter.pins_map,
}
end
end

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
class Context < ActiveModelSerializers::Model
attributes :ancestors, :descendants
attributes :ancestors, :descendants, :references
end

View file

@ -19,13 +19,14 @@ class Notification < ApplicationRecord
include Paginable
LEGACY_TYPE_CLASS_MAP = {
'Mention' => :mention,
'Status' => :reblog,
'Follow' => :follow,
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'Poll' => :poll,
'EmojiReaction' => :emoji_reaction,
'Mention' => :mention,
'Status' => :reblog,
'Follow' => :follow,
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'Poll' => :poll,
'EmojiReaction' => :emoji_reaction,
'StatusReference' => :status_reference,
}.freeze
TYPES = %i(
@ -37,6 +38,7 @@ class Notification < ApplicationRecord
favourite
poll
emoji_reaction
status_reference
).freeze
TARGET_STATUS_INCLUDES_BY_TYPE = {
@ -46,19 +48,21 @@ class Notification < ApplicationRecord
favourite: [favourite: :status],
poll: [poll: :status],
emoji_reaction: [emoji_reaction: :status],
status_reference: [status_reference: :status],
}.freeze
belongs_to :account, optional: true
belongs_to :from_account, class_name: 'Account', optional: true
belongs_to :activity, polymorphic: true, optional: true
belongs_to :mention, foreign_key: 'activity_id', optional: true
belongs_to :status, foreign_key: 'activity_id', optional: true
belongs_to :follow, foreign_key: 'activity_id', optional: true
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
belongs_to :favourite, foreign_key: 'activity_id', optional: true
belongs_to :poll, foreign_key: 'activity_id', optional: true
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
belongs_to :mention, foreign_key: 'activity_id', optional: true
belongs_to :status, foreign_key: 'activity_id', optional: true
belongs_to :follow, foreign_key: 'activity_id', optional: true
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
belongs_to :favourite, foreign_key: 'activity_id', optional: true
belongs_to :poll, foreign_key: 'activity_id', optional: true
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
belongs_to :status_reference, foreign_key: 'activity_id', optional: true
validates :type, inclusion: { in: TYPES }
validates :activity_id, uniqueness: { scope: [:account_id, :type] }, if: -> { type.to_sym == :status }
@ -93,6 +97,8 @@ class Notification < ApplicationRecord
poll&.status
when :emoji_reaction
emoji_reaction&.status
when :status_reference
status_reference&.status
end
end
@ -133,6 +139,8 @@ class Notification < ApplicationRecord
notification.poll.status = cached_status
when :emoji_reaction
notification.emoji_reaction.status = cached_status
when :status_reference
notification.status_reference.status = cached_status
end
end
@ -151,7 +159,7 @@ class Notification < ApplicationRecord
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction'
self.from_account_id = activity&.account_id
when 'Mention'
when 'Mention', 'StatusReference'
self.from_account_id = activity&.status&.account_id
end
end

View file

@ -79,6 +79,11 @@ class Status < ApplicationRecord
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
has_many :reference_relationships, class_name: 'StatusReference', foreign_key: :status_id, dependent: :destroy
has_many :references, through: :reference_relationships, source: :target_status
has_many :referred_by_relationships, class_name: 'StatusReference', foreign_key: :target_status_id, dependent: :destroy
has_many :referred_by, through: :referred_by_relationships, source: :status
has_one :notification, as: :activity, dependent: :destroy
has_one :status_stat, inverse_of: :status
has_one :poll, inverse_of: :status, dependent: :destroy
@ -134,6 +139,7 @@ class Status < ApplicationRecord
:tags,
:preview_cards,
:preloadable_poll,
references: { account: :account_stat },
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
reblog: [
@ -145,6 +151,7 @@ class Status < ApplicationRecord
:status_stat,
:status_expire,
:preloadable_poll,
references: { account: :account_stat },
account: [:account_stat, :user],
active_mentions: { account: :account_stat },
],
@ -165,12 +172,14 @@ class Status < ApplicationRecord
ids += reblogs.where(account: Account.local).pluck(:account_id)
ids += bookmarks.where(account: Account.local).pluck(:account_id)
ids += emoji_reactions.where(account: Account.local).pluck(:account_id)
ids += referred_by_statuses.where(account: Account.local).pluck(:account_id)
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || []
ids += preloaded.emoji_reactions[id] || []
ids += preloaded.status_references[id] || []
end
ids.uniq
@ -246,6 +255,10 @@ class Status < ApplicationRecord
public_visibility? || unlisted_visibility?
end
def public_safety?
distributable? && (!with_media? || non_sensitive_with_media?) && !account.silenced? && !account.suspended?
end
def sign?
distributable? || limited_visibility?
end
@ -303,6 +316,14 @@ class Status < ApplicationRecord
status_stat&.emoji_reactions_count || 0
end
def status_references_count
status_stat&.status_references_count || 0
end
def status_referred_by_count
status_stat&.status_referred_by_count || 0
end
def grouped_emoji_reactions(account = nil)
(Oj.load(status_stat&.emoji_reactions_cache || '', mode: :strict) || []).tap do |emoji_reactions|
if account.present?
@ -326,6 +347,16 @@ class Status < ApplicationRecord
end
end
def referred_by_statuses(account)
statuses = referred_by.includes(:account).to_a
account_ids = statuses.map(&:account_id).uniq
account_relations = Status.relations_map_for_account(account, account_ids)
status_relations = Status.relations_map_for_status(account, statuses)
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
statuses.sort!.reverse!
end
def increment_count!(key)
update_status_stat!(key => public_send(key) + 1)
end
@ -382,6 +413,34 @@ class Status < ApplicationRecord
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
end
def relations_map_for_account(account, account_ids)
return {} if account.nil?
presenter = AccountRelationshipsPresenter.new(account_ids, account)
{
blocking: presenter.blocking,
blocked_by: presenter.blocked_by,
muting: presenter.muting,
following: presenter.following,
subscribing: presenter.subscribing,
domain_blocking_by_domain: presenter.domain_blocking,
}
end
def relations_map_for_status(account, statuses)
return {} if account.nil?
presenter = StatusRelationshipsPresenter.new(statuses, account)
{
reblogs_map: presenter.reblogs_map,
favourites_map: presenter.favourites_map,
bookmarks_map: presenter.bookmarks_map,
emoji_reactions_map: presenter.emoji_reactions_map,
mutes_map: presenter.mutes_map,
pins_map: presenter.pins_map,
}
end
def reload_stale_associations!(cached_items)
account_ids = []
@ -402,6 +461,16 @@ class Status < ApplicationRecord
end
end
def permitted_statuses_from_ids(ids, account)
statuses = Status.with_accounts(ids).to_a
account_ids = statuses.map(&:account_id).uniq
account_relations = relations_map_for_account(account, account_ids)
status_relations = relations_map_for_status(account, statuses)
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
statuses
end
def permitted_for(target_account, account)
visibility = [:public, :unlisted]

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: status_references
#
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# target_status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusReference < ApplicationRecord
include Paginable
update_index('statuses', :target_status)
belongs_to :status
belongs_to :target_status, class_name: 'Status'
has_one :notification, as: :activity, dependent: :destroy
validates :target_status_id, uniqueness: { scope: :status_id }
validates_with StatusReferenceValidator
after_create :increment_cache_counters
after_destroy :decrement_cache_counters
private
def increment_cache_counters
status&.increment_count!(:status_references_count)
target_status&.increment_count!(:status_referred_by_count)
end
def decrement_cache_counters
status&.decrement_count!(:status_references_count)
target_status&.decrement_count!(:status_referred_by_count)
end
end

View file

@ -3,15 +3,17 @@
#
# Table name: status_stats
#
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# replies_count :bigint(8) default(0), not null
# reblogs_count :bigint(8) default(0), not null
# favourites_count :bigint(8) default(0), not null
# emoji_reactions_count :bigint(8) default(0), not null
# emoji_reactions_cache :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint(8) not null, primary key
# status_id :bigint(8) not null
# replies_count :bigint(8) default(0), not null
# reblogs_count :bigint(8) default(0), not null
# favourites_count :bigint(8) default(0), not null
# emoji_reactions_count :bigint(8) default(0), not null
# emoji_reactions_cache :string default(""), not null
# status_references_count :bigint(8) default(0), not null
# status_referred_by_count :bigint(8) default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusStat < ApplicationRecord

View file

@ -134,6 +134,8 @@ class User < ApplicationRecord
:hide_statuses_count, :hide_following_count, :hide_followers_count, :disable_joke_appearance,
:new_features_policy,
:theme_instance_ticker,
:enable_status_reference, :match_visibility_of_references,
:post_reference_modal, :add_reference_modal, :unselect_reference_modal,
to: :settings, prefix: :setting, allow_nil: false

View file

@ -53,6 +53,12 @@ class StatusPolicy < ApplicationPolicy
limited? && owned? && (!reply? || record.thread.conversation_id != record.conversation_id)
end
def subscribe?
return false unless show?
!unlisted? || owned? || following_author? || mention_exists?
end
private
def requires_mention?
@ -63,6 +69,10 @@ class StatusPolicy < ApplicationPolicy
author.id == current_account&.id
end
def unlisted?
record.unlisted_visibility?
end
def private?
record.private_visibility?
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ActivityPub::NoteSerializer < ActivityPub::Serializer
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry, :references
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@ -21,6 +21,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
has_many :virtual_tags, key: :tag
has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local?
has_one :references, serializer: ActivityPub::CollectionSerializer, if: :local?
has_many :poll_options, key: :one_of, if: :poll_and_not_multiple?
has_many :poll_options, key: :any_of, if: :poll_and_multiple?
@ -66,6 +67,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
)
end
INLINE_REFERENCE_MAX = 5
def references
@references = Status.where(id: object.reference_relationships.order(target_status_id: :asc).limit(INLINE_REFERENCE_MAX).pluck(:target_status_id)).reorder(id: :asc)
last_id = @references&.last&.id if @references.size == INLINE_REFERENCE_MAX
ActivityPub::CollectionPresenter.new(
type: :unordered,
id: ActivityPub::TagManager.instance.references_uri_for(object),
first: ActivityPub::CollectionPresenter.new(
type: :unordered,
part_of: ActivityPub::TagManager.instance.references_uri_for(object),
items: @references.map(&:uri),
next: last_id ? ActivityPub::TagManager.instance.references_uri_for(object, page: true, min_id: last_id) : nil,
),
)
end
def language?
object.language.present?
end

View file

@ -2,7 +2,7 @@
class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts, :lists,
:media_attachments, :settings
:media_attachments, :status_references, :settings
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -58,6 +58,12 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:disable_joke_appearance] = object.current_account.user.setting_disable_joke_appearance
store[:new_features_policy] = object.current_account.user.setting_new_features_policy
store[:theme_instance_ticker] = object.current_account.user.setting_theme_instance_ticker
store[:enable_status_reference] = object.current_account.user.setting_enable_status_reference
store[:match_visibility_of_references] = object.current_account.user.setting_match_visibility_of_references
store[:post_reference_modal] = object.current_account.user.setting_post_reference_modal
store[:add_reference_modal] = object.current_account.user.setting_add_reference_modal
store[:unselect_reference_modal] = object.current_account.user.setting_unselect_reference_modal
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media
@ -100,6 +106,10 @@ class InitialStateSerializer < ActiveModel::Serializer
{ accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
end
def status_references
{ max_references: StatusReferenceValidator::LIMIT }
end
private
def instance_presenter

View file

@ -3,4 +3,5 @@
class REST::ContextSerializer < ActiveModel::Serializer
has_many :ancestors, serializer: REST::StatusSerializer
has_many :descendants, serializer: REST::StatusSerializer
has_many :references, serializer: REST::StatusSerializer
end

View file

@ -85,6 +85,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
emoji_reactions: {
max_reactions: EmojiReactionValidator::LIMIT,
},
status_references: {
max_references: StatusReferenceValidator::LIMIT,
},
}
end
@ -129,6 +133,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:emoji_reaction,
:misskey_birthday,
:misskey_location,
:status_reference,
]
end

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction].include?(object.type)
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction, :status_reference].include?(object.type)
end
def reblog?

View file

@ -8,6 +8,29 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
:provider_url, :html, :width, :height,
:image, :embed_url, :blurhash
attribute :status_id, if: :status_id
attribute :account_id, if: :account_id
attr_reader :status, :account
def initialize(object, options = {})
super
return if object.nil?
@status = EntityCache.instance.holding_status(object.url.delete_suffix('/references'))
@account = @status&.account
@account = EntityCache.instance.holding_account(object.url) if @status.nil?
end
def status_id
status.id.to_s if status.present?
end
def account_id
account.id.to_s if account.present?
end
def image
object.image? ? full_asset_url(object.image.url(:original)) : nil
end

View file

@ -4,7 +4,9 @@ class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language,
:uri, :url, :replies_count, :reblogs_count,
:favourites_count, :emoji_reactions_count, :emoji_reactions
:favourites_count, :emoji_reactions_count, :emoji_reactions,
:status_reference_ids,
:status_references_count, :status_referred_by_count
attribute :favourited, if: :current_user?
attribute :reblogged, if: :current_user?
@ -144,6 +146,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.grouped_emoji_reactions(current_user&.account)
end
def status_reference_ids
object.references.map(&:id).map(&:to_s)
end
def reblogged
if instance_options && instance_options[:relationships]
instance_options[:relationships].reblogs_map[object.id] || false

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
class ActivityPub::FetchReferencesService < BaseService
include JsonLdHelper
def call(status, collection_or_uri)
@account = status.account
collection_items(collection_or_uri)&.map { |item| value_or_id(item) }
end
private
def collection_items(collection_or_uri)
collection = fetch_collection(collection_or_uri)
return unless collection.is_a?(Hash) && collection['first'].present?
all_items = []
collection = fetch_collection(collection['first'])
while collection.is_a?(Hash)
items = begin
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
end
end
break if items.blank?
all_items.concat(items)
break if all_items.size >= StatusReferenceValidator::LIMIT
collection = collection['next'].present? ? fetch_collection(collection['next']) : nil
end
all_items
end
def fetch_collection(collection_or_uri)
return collection_or_uri if collection_or_uri.is_a?(Hash)
return if invalid_origin?(collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true)
end
end

View file

@ -65,6 +65,7 @@ class FetchLinkCardService < BaseService
def parse_urls
if @status.local?
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
urls.push(Addressable::URI.parse(references_short_account_status_url(@status.account, @status))) if @status.references.exists?
else
html = Nokogiri::HTML(@status.text)
links = html.css(':not(.quote-inline) > a')
@ -76,7 +77,12 @@ class FetchLinkCardService < BaseService
def bad_url?(uri)
# Avoid local instance URLs and invalid URLs
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && !status_reference_url?(uri.to_s)) || !%w(http https).include?(uri.scheme)
end
def status_reference_url?(uri)
recognized_params = Rails.application.routes.recognize_path(uri)
recognized_params && recognized_params[:controller] == 'statuses' && recognized_params[:action] == 'references'
end
# rubocop:disable Naming/MethodParameterName

View file

@ -50,6 +50,10 @@ class NotifyService < BaseService
false
end
def blocked_status_reference?
FeedManager.instance.filter?(:status_references, @notification.status_reference.status, @recipient)
end
def following_sender?
return @following_sender if defined?(@following_sender)
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)

View file

@ -103,6 +103,7 @@ class PostStatusService < BaseService
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status, @circle)
ProcessStatusReferenceService.new.call(@status, status_reference_ids: (@options[:status_reference_ids] || []) + [@quote_id], urls: @options[:status_reference_urls])
end
def schedule_status!

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
class ProcessStatusReferenceService
def call(status, **options)
@status = status
urls = parse_urls(status, options).union(options[:urls]) - [ActivityPub::TagManager.instance.uri_for(status), ActivityPub::TagManager.instance.url_for(status)]
process_reference(urls, (options[:status_reference_ids] || []).compact.uniq, status.id)
end
private
def parse_urls(status, **options)
if status.local?
parse_local_urls(status.text)
else
mentions = options[:mentions] || status.mentions
parse_remote_urls(status.text, mentions)
end
end
def process_reference(urls, ids, status_id)
target_statuses = urls_to_target_statuses(urls, status_id).uniq
target_statuses = target_statuses + Status.where(id: ids - target_statuses.map(&:id)).where(visibility: [:public, :unlisted, :private])
references = target_statuses.filter_map do |target_status|
StatusReference.create(status_id: status_id, target_status_id: target_status.id)
end
references.group_by{|reference| reference.target_status.account}.each do |account, grouped_references|
create_notification(grouped_references.sort_by(&:id).first) if account.local?
end
end
def create_notification(reference)
NotifyService.new.call(reference.target_status.account, :status_reference, reference)
end
def parse_local_urls(text)
text.scan(FetchLinkCardService::URL_PATTERN).map(&:second).uniq.filter_map do |url|
Addressable::URI.parse(url).normalize.to_s
rescue Addressable::URI::InvalidURIError
nil
end
end
def parse_remote_urls(text, mentions = [])
html = Nokogiri::HTML(text)
links = html.css(':not(.reference-link-inline) > a')
links.filter_map do |anchor|
Addressable::URI.parse(anchor['href']).normalize.to_s unless skip_link?(anchor, mentions)
rescue Addressable::URI::InvalidURIError
nil
end
end
def urls_to_target_statuses(urls, status_id)
urls.uniq.filter_map do |url|
url_to_target_status(url).tap do |target_status|
if target_status.nil? && !TagManager.instance.local_url?(url)
StatusReferenceResolveWorker.perform_async(status_id, url)
end
end
end
end
def url_to_target_status(url)
if TagManager.instance.local_url?(url)
ActivityPub::TagManager.instance.uri_to_resource(url, Status)
else
EntityCache.instance.holding_status(url)
end
end
def mention_link?(anchor, mentions)
mentions.any? do |mention|
anchor['href'] == ActivityPub::TagManager.instance.url_for(mention.account)
end
end
def skip_link?(anchor, mentions)
# Avoid links for hashtags and mentions (microformats)
anchor['rel']&.include?('tag') || anchor['class']&.match?(/u-url|h-card/) || mention_link?(anchor, mentions)
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class StatusReferenceValidator < ActiveModel::Validator
LIMIT = 100
def validate(reference)
reference.errors.add(:name, I18n.t('status_references.errors.limit')) if reference.status.reference_relationships.count >= LIMIT && reference.status.account.local?
reference.errors.add(:name, I18n.t('status_references.errors.visibility')) unless reference.target_status.distributable? || reference.target_status.private_visibility?
end
end

View file

@ -0,0 +1,45 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_link.png'), alt: ''
%h1= t 'notification_mailer.status_reference.title'
%p.lead= t('notification_mailer.status_reference.body', name: @status.account.acct)
= render 'status', status: @status
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start.border-top
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to web_url("statuses/#{@status.id}") do
%span= t 'application_mailer.view_status'

View file

@ -0,0 +1,5 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('notification_mailer.status_reference.body', name: @account.acct) %>
<%= render 'status', status: @status %>

View file

@ -61,6 +61,9 @@
= f.input :setting_unsubscribe_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
= f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
= f.input :setting_post_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_add_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_unselect_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
%h4= t 'appearance.sensitive_content'

View file

@ -19,6 +19,7 @@
= ff.input :favourite, as: :boolean, wrapper: :with_label
= ff.input :emoji_reaction, as: :boolean, wrapper: :with_label, fedibird_features: true
= ff.input :mention, as: :boolean, wrapper: :with_label
= ff.input :status_reference, as: :boolean, wrapper: :with_label, fedibird_features: true
- if current_user.staff?
= ff.input :report, as: :boolean, wrapper: :with_label

View file

@ -82,6 +82,12 @@
.fields-group
= f.input :setting_show_reply_tree_button, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_enable_status_reference, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_match_visibility_of_references, as: :boolean, wrapper: :with_label, fedibird_features: true
-# .fields-group
-# = f.input :setting_show_target, as: :boolean, wrapper: :with_label

View file

@ -1,4 +1,4 @@
- description = status_description(activity)
- description = status_description(activity) unless description
%meta{ name: 'description', content: description }/
= opengraph 'og:description', description

View file

@ -0,0 +1,28 @@
:ruby
is_predecessor ||= false
is_successor ||= false
direct_reply_id ||= false
parent_id ||= false
is_direct_parent = direct_reply_id == status.id
is_direct_child = parent_id == status.in_reply_to_id
centered ||= !is_predecessor && !is_successor
h_class = microformats_h_class(status, is_predecessor, is_successor, true)
style_classes = style_classes(status, is_predecessor, is_successor, true)
mf_classes = microformats_classes(status, is_direct_parent, is_direct_child)
entry_classes = h_class + ' ' + mf_classes + ' ' + style_classes
- if max_id
.entry{ class: entry_classes }
= link_to_older references_short_account_status_url(status.account.username, status, max_id: max_id)
= render partial: 'statuses/status', collection: @references, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id }
- if min_id
.entry{ class: entry_classes }
= link_to_newer references_short_account_status_url(status.account.username, status, min_id: min_id)
- if !embedded_view? && !user_signed_in?
.entry{ class: entry_classes }
= link_to new_user_session_path, class: 'load-more load-gap' do
= fa_icon 'comments'
= t('statuses.sign_in_to_participate')

Some files were not shown because too many files have changed in this diff Show more