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
app
chewy
controllers
helpers
javascript
lib
mailers
models
policies
serializers
services
validators
views

View file

@ -64,6 +64,11 @@ class StatusesIndex < Chewy::Index
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end 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 root date_detection: false do
field :id, type: 'long' field :id, type: 'long'
field :account_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 = params.permit(exclude_types: [])[:exclude_types] || []
val = [val] unless val.is_a?(Enumerable) val = [val] unless val.is_a?(Enumerable)
val = val << 'emoji_reaction' << 'status' unless new_notification_type_compatible? 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 val.uniq
end end

View file

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

View file

@ -6,6 +6,7 @@ module StatusControllerConcern
ANCESTORS_LIMIT = 40 ANCESTORS_LIMIT = 40
DESCENDANTS_LIMIT = 60 DESCENDANTS_LIMIT = 60
DESCENDANTS_DEPTH_LIMIT = 20 DESCENDANTS_DEPTH_LIMIT = 20
REFERENCES_LIMIT = 60
def create_descendant_thread(starting_depth, statuses) def create_descendant_thread(starting_depth, statuses)
depth = starting_depth + statuses.size depth = starting_depth + statuses.size
@ -26,8 +27,61 @@ module StatusControllerConcern
end end
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 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 @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
end end

View file

@ -79,7 +79,12 @@ class Settings::PreferencesController < Settings::BaseController
:setting_disable_joke_appearance, :setting_disable_joke_appearance,
:setting_new_features_policy, :setting_new_features_policy,
:setting_theme_instance_ticker, :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) interactions: %i(must_be_follower must_be_following must_be_following_dm)
) )
end end

View file

@ -12,13 +12,13 @@ class StatusesController < ApplicationController
before_action :set_status before_action :set_status
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_link_headers before_action :set_link_headers
before_action :redirect_to_original, only: :show before_action :redirect_to_original, only: [:show, :references]
before_action :set_referrer_policy_header, only: :show before_action :set_referrer_policy_header, only: [:show, :references]
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes before_action :set_body_classes
skip_around_action :set_locale, if: -> { request.format == :json } 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| content_security_policy only: :embed do |p|
p.frame_ancestors(false) p.frame_ancestors(false)
@ -39,6 +39,20 @@ class StatusesController < ApplicationController
end end
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 def activity
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? 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 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' }, quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' },
expiry: { 'fedibird' => 'http://fedibird.com/ns#', 'expiry' => 'fedibird:expiry' }, expiry: { 'fedibird' => 'http://fedibird.com/ns#', 'expiry' => 'fedibird:expiry' },
other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, 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' }, is_cat: { 'misskey' => 'https://misskey-hub.net/ns#', 'isCat' => 'misskey:isCat' },
vcard: { 'vcard' => 'http://www.w3.org/2006/vcard/ns#' }, vcard: { 'vcard' => 'http://www.w3.org/2006/vcard/ns#' },
}.freeze }.freeze

View file

@ -52,7 +52,7 @@ module JsonLdHelper
end end
def same_origin?(url_a, url_b) 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 end
def invalid_origin?(url) def invalid_origin?(url)

View file

@ -54,7 +54,18 @@ module StatusesHelper
components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')] components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')]
if status.spoiler_text.blank? 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) components << poll_summary(status)
end end
@ -113,6 +124,10 @@ module StatusesHelper
end end
end end
def noindex?(statuses)
statuses.map(&:account).uniq.any?(&:noindex?)
end
private private
def simplified_text(text) def simplified_text(text)

Binary file not shown.

After

(image error) Size: 1.9 KiB

View file

@ -13,6 +13,8 @@ import { showAlert } from './alerts';
import { openModal } from './modal'; import { openModal } from './modal';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { addYears, addMonths, addDays, addHours, addMinutes, addSeconds, millisecondsToSeconds, set, parseISO, formatISO } from 'date-fns'; 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; 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_CHANGE = 'COMPOSE_EXPIRES_CHANGE';
export const COMPOSE_EXPIRES_ACTION_CHANGE = 'COMPOSE_EXPIRES_ACTION_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({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, 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); 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) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -105,6 +123,7 @@ export function replyCompose(status, routerHistory) {
dispatch({ dispatch({
type: COMPOSE_REPLY, type: COMPOSE_REPLY,
status: status, status: status,
context_references: getContextReference(getState, status),
}); });
ensureComposeIsVisible(getState, routerHistory); 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) { export function submitCompose(routerHistory) {
return function (dispatch, getState) { return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], ''); 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: 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 { 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 expires_action = getState().getIn(['compose', 'expires_action']);
const statusReferenceIds = getState().getIn(['compose', 'references']);
if ((!status || !status.length) && media.size === 0) { if ((!status || !status.length) && media.size === 0) {
return; return;
@ -234,6 +276,7 @@ export function submitCompose(routerHistory) {
expires_at: !expires_in && expires_at ? formatISO(set(expires_at, { seconds: 59 })) : null, expires_at: !expires_in && expires_at ? formatISO(set(expires_at, { seconds: 59 })) : null,
expires_in: expires_in, expires_in: expires_in,
expires_action: expires_action, expires_action: expires_action,
status_reference_ids: statusReferenceIds,
}, { }, {
headers: { headers: {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
@ -810,3 +853,34 @@ export function changeExpiresAction(value) {
value: 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 // Otherwise keep the ones already in the reducer
if (normalOldStatus) { if (normalOldStatus) {
normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.shortHtml = normalOldStatus.get('shortHtml');
normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
@ -73,11 +74,16 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoiler_text = ''; normalStatus.spoiler_text = '';
} }
const spoilerText = 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 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 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.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;

View file

@ -1,6 +1,6 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
import { fetchRelationships } from './accounts'; import { fetchRelationships, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
import { me } from '../initial_state'; import { me } from '../initial_state';
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; 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_SUCCESS = 'EMOJI_REACTIONS_EXPAND_SUCCESS';
export const EMOJI_REACTIONS_EXPAND_FAIL = 'EMOJI_REACTIONS_EXPAND_FAIL'; 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_REQUEST = 'MENTIONS_FETCH_REQUEST';
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS'; export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL'; export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL';
@ -337,6 +345,7 @@ export function fetchReblogsSuccess(id, accounts, next) {
export function fetchReblogsFail(id, error) { export function fetchReblogsFail(id, error) {
return { return {
type: REBLOGS_FETCH_FAIL, type: REBLOGS_FETCH_FAIL,
id,
error, error,
}; };
}; };
@ -381,6 +390,7 @@ export function expandReblogsSuccess(id, accounts, next) {
export function expandReblogsFail(id, error) { export function expandReblogsFail(id, error) {
return { return {
type: REBLOGS_EXPAND_FAIL, type: REBLOGS_EXPAND_FAIL,
id,
error, error,
}; };
}; };
@ -419,6 +429,7 @@ export function fetchFavouritesSuccess(id, accounts, next) {
export function fetchFavouritesFail(id, error) { export function fetchFavouritesFail(id, error) {
return { return {
type: FAVOURITES_FETCH_FAIL, type: FAVOURITES_FETCH_FAIL,
id,
error, error,
}; };
}; };
@ -463,6 +474,7 @@ export function expandFavouritesSuccess(id, accounts, next) {
export function expandFavouritesFail(id, error) { export function expandFavouritesFail(id, error) {
return { return {
type: FAVOURITES_EXPAND_FAIL, type: FAVOURITES_EXPAND_FAIL,
id,
error, error,
}; };
}; };
@ -501,6 +513,7 @@ export function fetchEmojiReactionsSuccess(id, emojiReactions, next) {
export function fetchEmojiReactionsFail(id, error) { export function fetchEmojiReactionsFail(id, error) {
return { return {
type: EMOJI_REACTIONS_FETCH_FAIL, type: EMOJI_REACTIONS_FETCH_FAIL,
id,
error, error,
}; };
}; };
@ -545,6 +558,95 @@ export function expandEmojiReactionsSuccess(id, emojiReactions, next) {
export function expandEmojiReactionsFail(id, error) { export function expandEmojiReactionsFail(id, error) {
return { return {
type: EMOJI_REACTIONS_EXPAND_FAIL, 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, error,
}; };
}; };
@ -583,6 +685,7 @@ export function fetchMentionsSuccess(id, accounts, next) {
export function fetchMentionsFail(id, error) { export function fetchMentionsFail(id, error) {
return { return {
type: MENTIONS_FETCH_FAIL, type: MENTIONS_FETCH_FAIL,
id,
error, error,
}; };
}; };
@ -627,6 +730,7 @@ export function expandMentionsSuccess(id, accounts, next) {
export function expandMentionsFail(id, error) { export function expandMentionsFail(id, error) {
return { return {
type: MENTIONS_EXPAND_FAIL, type: MENTIONS_EXPAND_FAIL,
id,
error, error,
}; };
}; };

View file

@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { getFiltersRegex } from '../selectors'; 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 compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications'; import { requestNotificationPermission } from '../utils/notifications';
@ -66,7 +66,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; let filtered = false;
if (['mention', 'status'].includes(notification.type)) { if (['mention', 'status', 'status_reference'].includes(notification.type)) {
const dropRegex = filters[0]; const dropRegex = filters[0];
const regex = filters[1]; const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status); 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 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 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(); return allTypes.filterNot(item => item === filter).toJS();
}; };

View file

@ -3,12 +3,16 @@ import api from '../api';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import { fetchRelationshipsFromStatus, fetchAccountsFromStatus, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts'; import { fetchRelationshipsFromStatus, fetchAccountsFromStatus, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer'; 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_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; 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_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; 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 { return {
type: REDRAFT, type: REDRAFT,
status, status,
replyStatus, replyStatus,
raw_text, raw_text,
context_references: getContextReference(getState, replyStatus),
}; };
}; };
@ -109,7 +159,8 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(importFetchedAccount(response.data.account)); dispatch(importFetchedAccount(response.data.account));
if (withRedraft) { 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); ensureComposeIsVisible(getState, routerHistory);
} }
}).catch(error => { }).catch(error => {
@ -144,12 +195,12 @@ export function fetchContext(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchContextRequest(id)); dispatch(fetchContextRequest(id));
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { api(getState).get(`/api/v1/statuses/${id}/context`, { params: { with_reference: true } }).then(response => {
const statuses = response.data.ancestors.concat(response.data.descendants); const statuses = response.data.ancestors.concat(response.data.descendants).concat(response.data.references);
dispatch(importFetchedStatuses(statuses)); dispatch(importFetchedStatuses(statuses));
dispatch(fetchRelationshipsFromStatuses(statuses)); dispatch(fetchRelationshipsFromStatuses(statuses));
dispatch(fetchAccountsFromStatuses(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 => { }).catch(error => {
if (error.response && error.response.status === 404) { 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 { return {
type: CONTEXT_FETCH_SUCCESS, type: CONTEXT_FETCH_SUCCESS,
id, id,
ancestors, ancestors,
descendants, descendants,
references,
statuses: ancestors.concat(descendants), statuses: ancestors.concat(descendants),
}; };
}; };

View file

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

View file

@ -10,7 +10,6 @@ import DisplayName from './display_name';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import AccountActionBar from './account_action_bar'; import AccountActionBar from './account_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@ -20,7 +19,8 @@ import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar'; import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; 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 // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // 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' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual-followers-only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, 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 = { const dateFormatOptions = {
@ -108,6 +111,8 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list, otherAccounts: ImmutablePropTypes.list,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
quote_muted: PropTypes.bool, quote_muted: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
onReply: PropTypes.func, onReply: PropTypes.func,
@ -126,6 +131,7 @@ class Status extends ImmutablePureComponent {
onToggleHidden: PropTypes.func, onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func, onToggleCollapsed: PropTypes.func,
onQuoteToggleHidden: PropTypes.func, onQuoteToggleHidden: PropTypes.func,
onReference: PropTypes.func,
onAddToList: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired,
muted: PropTypes.bool, muted: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
@ -158,6 +164,8 @@ class Status extends ImmutablePureComponent {
'hidden', 'hidden',
'unread', 'unread',
'pictureInPicture', 'pictureInPicture',
'referenced',
'contextReferenced',
'quote_muted', 'quote_muted',
]; ];
@ -373,7 +381,7 @@ class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText; let statusAvatar, prepend, rebloggedByText;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, 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; let { status, account, ...other } = this.props;
@ -685,17 +693,51 @@ class Status extends ImmutablePureComponent {
const expires_date = expires_at && new Date(expires_at) const expires_date = expires_at && new Date(expires_at)
const expired = expires_date && expires_date.getTime() < intl.now() 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 ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted, '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} {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} /> <AccountActionBar account={status.get('account')} {...other} />
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'> <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>} {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> <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'> <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 DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; 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 classNames from 'classnames';
import { openModal } from '../actions/modal';
import ReactionPickerDropdownContainer from '../containers/reaction_picker_dropdown_container'; import ReactionPickerDropdownContainer from '../containers/reaction_picker_dropdown_container';
@ -23,6 +24,7 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -37,6 +39,7 @@ const messages = defineMessages({
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' }, show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited 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_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}' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute 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' }, openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{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 }) => ({ const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), 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) export default @connect(mapStateToProps)
@ -68,7 +78,12 @@ class StatusActionBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
expired: PropTypes.bool, expired: PropTypes.bool,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
relationship: ImmutablePropTypes.map, relationship: ImmutablePropTypes.map,
referenceCountLimit: PropTypes.bool,
selected: PropTypes.bool,
composePrivacy: PropTypes.string,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
@ -88,6 +103,8 @@ class StatusActionBar extends ImmutablePureComponent {
onMuteConversation: PropTypes.func, onMuteConversation: PropTypes.func,
onPin: PropTypes.func, onPin: PropTypes.func,
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
onAddReference: PropTypes.func,
onRemoveReference: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -105,6 +122,9 @@ class StatusActionBar extends ImmutablePureComponent {
'status', 'status',
'relationship', 'relationship',
'withDismiss', 'withDismiss',
'referenced',
'contextReferenced',
'referenceCountLimit'
] ]
handleReplyClick = () => { 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 => { _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'); 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`); 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 => { handleEmojiPick = data => {
const { addEmojiReaction, status } = this.props; const { addEmojiReaction, status } = this.props;
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null); addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
@ -277,7 +326,7 @@ class StatusActionBar extends ImmutablePureComponent {
} }
render () { 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 anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -290,6 +339,7 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarked = status.get('bookmarked'); const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned'); const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count'); const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count'); const favouritesCount = status.get('favourites_count');
const [ _, domain ] = account.get('acct').split('@'); 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 }); 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) { if (domain) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline }); 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} /> <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 ( return (
<div className='status__action-bar'> <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 /> <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={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} /> <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} />} {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 classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container'; import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon'; 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({ const messages = defineMessages({
linkToAcct: { id: 'status.link_to_acct', defaultMessage: 'Link to @{acct}' }, linkToAcct: { id: 'status.link_to_acct', defaultMessage: 'Link to @{acct}' },
@ -39,13 +39,22 @@ export default class StatusContent extends React.PureComponent {
}; };
_updateStatusLinks () { _updateStatusLinks () {
const { intl, status, collapsable, onClick, onCollapsedToggle } = this.props;
const node = this.node; const node = this.node;
if (!node) { if (!node) {
return; 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) { for (var i = 0; i < links.length; ++i) {
let link = links[i]; let link = links[i];
@ -54,7 +63,7 @@ export default class StatusContent extends React.PureComponent {
} }
link.classList.add('status-link'); 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) {
if (mention.get('group', false)) { 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] === '#')) { } 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); link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else if (link.classList.contains('account-url-link')) { } 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); link.addEventListener('click', this.onAccountUrlClick.bind(this, link.dataset.accountId, link.dataset.accountActorType), false);
} else if (link.classList.contains('status-url-link')) { } else if (link.classList.contains('status-url-link') && ![status.get('uri'), status.get('url')].includes(link.href)) {
link.setAttribute('title', this.props.intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct })); link.setAttribute('title', intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct }));
link.addEventListener('click', this.onStatusUrlClick.bind(this, link.dataset.statusId), false); link.addEventListener('click', this.onStatusUrlClick.bind(this, link.dataset.statusId), false);
} else { } else {
link.setAttribute('title', link.href); link.setAttribute('title', link.href);
@ -80,16 +89,16 @@ export default class StatusContent extends React.PureComponent {
link.setAttribute('rel', 'noopener noreferrer'); link.setAttribute('rel', 'noopener noreferrer');
} }
if (this.props.status.get('collapsed', null) === null) { if (status.get('collapsed', null) === null) {
let collapsed = let collapsed =
this.props.collapsable collapsable
&& this.props.onClick && onClick
&& node.clientHeight > MAX_HEIGHT && 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) => { handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY]; 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 hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const renderViewThread = this.props.showThread && ( const renderViewThread = this.props.showThread && (
status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) || 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'))
); );
const renderShowPoll = !!status.get('poll'); 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, quoteCompose,
mentionCompose, mentionCompose,
directCompose, directCompose,
addReference,
removeReference,
} from '../actions/compose'; } from '../actions/compose';
import { import {
reblog, reblog,
@ -74,12 +76,21 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); const getPictureInPicture = makeGetPictureInPicture();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); 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) => ({ const mapStateToProps = (state, props) => {
status: getStatus(state, props), const status = getStatus(state, props);
pictureInPicture: getPictureInPicture(state, props), const id = !!status ? getProper(status).get('id') : null;
emojiMap: customEmojiMap(state),
}); 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; return mapStateToProps;
}; };
@ -308,6 +319,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(removeEmojiReaction(status)); dispatch(removeEmojiReaction(status));
}, },
onAddReference (id, change) {
dispatch(addReference(id, change));
},
onRemoveReference (id) {
dispatch(removeReference(id));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); 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 PollFormContainer from '../containers/poll_form_container';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import ReferenceStack from '../../../features/reference_stack';
import { isMobile } from '../../../is_mobile'; import { isMobile } from '../../../is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz'; import { length } from 'stringz';
@ -273,6 +274,8 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__publish'> <div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div> <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
</div> </div>
<ReferenceStack />
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
import { import {
changeCompose, changeCompose,
submitCompose, submitComposeWithCheck,
clearComposeSuggestions, clearComposeSuggestions,
fetchComposeSuggestions, fetchComposeSuggestions,
selectComposeSuggestion, selectComposeSuggestion,
@ -10,6 +10,7 @@ import {
insertEmojiCompose, insertEmojiCompose,
uploadCompose, uploadCompose,
} from '../../../actions/compose'; } from '../../../actions/compose';
import { injectIntl } from 'react-intl';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
@ -28,14 +29,14 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (text) { onChange (text) {
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit (router) { onSubmit (router) {
dispatch(submitCompose(router)); dispatch(submitComposeWithCheck(router, intl));
}, },
onClearSuggestions () { 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 { connect } from 'react-redux';
import Upload from '../components/upload'; import Upload from '../components/upload';
import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose'; 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 }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
onUndo: id => { onUndo: id => {
dispatch(undoUploadCompose(id)); dispatch(undoUploadCompose(id));
@ -18,9 +19,9 @@ const mapDispatchToProps = dispatch => ({
}, },
onSubmit (router) { 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 ClearColumnButton from './clear_column_button';
import GrantPermissionButton from './grant_permission_button'; import GrantPermissionButton from './grant_permission_button';
import SettingToggle from './setting_toggle'; import SettingToggle from './setting_toggle';
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
export default class ColumnSettings extends React.PureComponent { export default class ColumnSettings extends React.PureComponent {
@ -153,16 +154,30 @@ export default class ColumnSettings extends React.PureComponent {
</div> </div>
</div> </div>
<div role='group' aria-labelledby='notifications-reaction'> {enableReaction &&
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span> <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'> <div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} /> <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', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />} {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status_reference']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status_reference']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status_reference']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
}
</div> </div>
); );
} }

View file

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

View file

@ -21,6 +21,7 @@ const messages = defineMessages({
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' }, status: { id: 'notification.status', defaultMessage: '{name} just posted' },
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' }, 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) => { 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 () { render () {
const { notification } = this.props; const { notification } = this.props;
const account = notification.get('account'); const account = notification.get('account');
@ -373,6 +406,8 @@ class Notification extends ImmutablePureComponent {
return this.renderPoll(notification, account); return this.renderPoll(notification, account);
case 'emoji_reaction': case 'emoji_reaction':
return this.renderReaction(notification, link); return this.renderReaction(notification, link);
case 'status_reference':
return this.renderStatusReference(notification, link);
} }
return null; return null;

View file

@ -5,9 +5,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import classNames from 'classnames'; 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 { 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 { reblog, favourite, bookmark, unreblog, unfavourite, unbookmark } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
import { initBoostModal } from 'mastodon/actions/boosts'; import { initBoostModal } from 'mastodon/actions/boosts';
@ -16,6 +16,7 @@ import { openModal } from 'mastodon/actions/modal';
const messages = defineMessages({ const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -29,15 +30,29 @@ const messages = defineMessages({
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' }, 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?' }, quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
open: { id: 'status.open', defaultMessage: 'Expand this status' }, open: { id: 'status.open', defaultMessage: 'Expand this status' },
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 makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => {
status: getStatus(state, { id: statusId }), const status = getStatus(state, { id: statusId });
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, 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; return mapStateToProps;
}; };
@ -53,6 +68,10 @@ class Footer extends ImmutablePureComponent {
static propTypes = { static propTypes = {
statusId: PropTypes.string.isRequired, statusId: PropTypes.string.isRequired,
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
referenceCountLimit: PropTypes.bool,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
composePrivacy: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
askReplyConfirmation: PropTypes.bool, 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 = () => { handleFavouriteClick = () => {
const { dispatch, status } = this.props; const { dispatch, status } = this.props;
@ -164,7 +216,7 @@ class Footer extends ImmutablePureComponent {
} }
render () { 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 publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; 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); reblogTitle = intl.formatMessage(messages.cannot_reblog);
} }
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
return ( return (
<div className='picture-in-picture__footer'> <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 /> <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={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{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} />} {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')}/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')}/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')}/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>
)} )}
</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 ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl'; 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 classNames from 'classnames';
import ReactionPickerDropdownContainer from 'mastodon/containers/reaction_picker_dropdown_container'; import ReactionPickerDropdownContainer from 'mastodon/containers/reaction_picker_dropdown_container';
import { openModal } from '../../../actions/modal';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -16,6 +17,7 @@ const messages = defineMessages({
showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' }, showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reference: { id: 'status.reference', defaultMessage: 'Reference' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -28,6 +30,7 @@ const messages = defineMessages({
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' }, show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited 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_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' }, more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, 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' }, openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{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 }) => ({ const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), 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) export default @connect(mapStateToProps)
@ -62,12 +72,19 @@ class ActionBar extends React.PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
relationship: ImmutablePropTypes.map, relationship: ImmutablePropTypes.map,
referenceCountLimit: PropTypes.bool,
selected: PropTypes.bool,
composePrivacy: PropTypes.string,
onReply: PropTypes.func.isRequired, onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func.isRequired, onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired, onBookmark: PropTypes.func.isRequired,
onAddReference: PropTypes.func,
onRemoveReference: PropTypes.func,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired,
onMemberList: PropTypes.func.isRequired, onMemberList: PropTypes.func.isRequired,
@ -95,6 +112,31 @@ class ActionBar extends React.PureComponent {
this.props.onReblog(this.props.status, e); 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 = () => { handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.context.router.history); 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`); 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 => { handleEmojiPick = data => {
const { addEmojiReaction, status } = this.props; const { addEmojiReaction, status } = this.props;
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null); addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
@ -235,7 +281,7 @@ class ActionBar extends React.PureComponent {
} }
render () { 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 publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
@ -247,6 +293,7 @@ class ActionBar extends React.PureComponent {
const bookmarked = status.get('bookmarked'); const bookmarked = status.get('bookmarked');
const emoji_reactioned = status.get('emoji_reactioned'); const emoji_reactioned = status.get('emoji_reactioned');
const reblogsCount = status.get('reblogs_count'); const reblogsCount = status.get('reblogs_count');
const referredByCount = status.get('status_referred_by_count');
const favouritesCount = status.get('favourites_count'); const favouritesCount = status.get('favourites_count');
const [ _, domain ] = account.get('acct').split('@'); 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 }); 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) { if (domain) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline }); 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); reblogTitle = intl.formatMessage(messages.cannot_reblog);
} }
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
return ( return (
<div className='detailed-status__action-bar'> <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> <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={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> <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>} {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 }); 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 () { renderVideo () {
const { card } = this.props; const { card } = this.props;
const content = { __html: addAutoPlay(card.get('html')) }; const content = { __html: addAutoPlay(card.get('html')) };
@ -288,7 +306,7 @@ export default class Card extends React.PureComponent {
} }
return ( 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} {embed}
{description} {description}
</a> </a>

View file

@ -18,7 +18,7 @@ import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number'; import AnimatedNumber from 'mastodon/components/animated_number';
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar'; import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; 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({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -71,6 +71,8 @@ class DetailedStatus extends ImmutablePureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
quote_muted: PropTypes.bool, quote_muted: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired,
@ -93,6 +95,7 @@ class DetailedStatus extends ImmutablePureComponent {
emojiMap: ImmutablePropTypes.map, emojiMap: ImmutablePropTypes.map,
addEmojiReaction: PropTypes.func.isRequired, addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired, removeEmojiReaction: PropTypes.func.isRequired,
onReference: PropTypes.func,
}; };
state = { 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 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 quote_muted = this.props.quote_muted
const outerStyle = { boxSizing: 'border-box' }; const outerStyle = { boxSizing: 'border-box' };
const { intl, compact, pictureInPicture } = this.props; const { intl, compact, pictureInPicture, referenced, contextReferenced } = this.props;
if (!status) { if (!status) {
return null; return null;
} }
let media = ''; let media = '';
let applicationLink = ''; let applicationLink = '';
let reblogLink = ''; let reblogLink = '';
let reblogIcon = 'retweet'; let reblogIcon = 'retweet';
let favouriteLink = ''; let favouriteLink = '';
let emojiReactionLink = ''; let emojiReactionLink = '';
let statusReferredByLink = '';
const reblogsCount = status.get('reblogs_count'); const reblogsCount = status.get('reblogs_count');
const favouritesCount = status.get('favourites_count'); const favouritesCount = status.get('favourites_count');
const emojiReactionsCount = status.get('emoji_reactions_count'); const emojiReactionsCount = status.get('emoji_reactions_count');
const statusReferredByCount = status.get('status_referred_by_count');
if (this.props.measureHeight) { if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
@ -387,41 +392,55 @@ class DetailedStatus extends ImmutablePureComponent {
if (this.context.router) { if (this.context.router) {
favouriteLink = ( favouriteLink = (
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> <Fragment>
<Icon id='star' /> <Fragment> · </Fragment>
<span className='detailed-status__favorites'> <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<AnimatedNumber value={favouritesCount} /> <Icon id='star' />
</span> <span className='detailed-status__favorites'>
</Link> <AnimatedNumber value={favouritesCount} />
</span>
</Link>
</Fragment>
); );
} else { } else {
favouriteLink = ( favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> <Fragment>
<Icon id='star' /> <Fragment> · </Fragment>
<span className='detailed-status__favorites'> <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<AnimatedNumber value={favouritesCount} /> <Icon id='star' />
</span> <span className='detailed-status__favorites'>
</a> <AnimatedNumber value={favouritesCount} />
</span>
</a>
</Fragment>
); );
} }
if (this.context.router) { if (enableReaction && this.context.router) {
emojiReactionLink = ( emojiReactionLink = (
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'> <Fragment>
<Icon id='smile-o' /> <Fragment> · </Fragment>
<span className='detailed-status__emoji_reactions'> <Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<AnimatedNumber value={emojiReactionsCount} /> <Icon id='smile-o' />
</span> <span className='detailed-status__emoji_reactions'>
</Link> <AnimatedNumber value={emojiReactionsCount} />
</span>
</Link>
</Fragment>
); );
} else { }
emojiReactionLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}> if (enableStatusReference && this.context.router) {
<Icon id='smile-o' /> statusReferredByLink = (
<span className='detailed-status__emoji_reactions'> <Fragment>
<AnimatedNumber value={emojiReactionsCount} /> <Fragment> · </Fragment>
</span> <Link to={`/statuses/${status.get('id')}/referred_by`} className='detailed-status__link'>
</a> <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 ( return (
<div style={outerStyle}> <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'> <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> <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} /> <DisplayName account={status.get('account')} localDomain={this.props.domain} />
@ -460,7 +479,7 @@ class DetailedStatus extends ImmutablePureComponent {
</time> </time>
</span> </span>
} }
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionLink} {visibilityLink}{applicationLink}{reblogLink}{favouriteLink}{emojiReactionLink}{statusReferredByLink}
</div> </div>
</div> </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 React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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 { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses'; import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator'; import MissingIndicator from '../../components/missing_indicator';
@ -28,6 +27,8 @@ import {
quoteCompose, quoteCompose,
mentionCompose, mentionCompose,
directCompose, directCompose,
addReference,
removeReference,
} from '../../actions/compose'; } from '../../actions/compose';
import { import {
muteStatus, muteStatus,
@ -59,10 +60,11 @@ import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys'; 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 { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status'; import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import DetailedHeaderContaier from './containers/header_container';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -83,12 +85,13 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture(); const getPictureInPicture = makeGetPictureInPicture();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); 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([ const getAncestorsIds = createSelector([
(_, { id }) => id, (_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']), state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => { ], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List(); let ancestorsIds = ImmutableList();
ancestorsIds = ancestorsIds.withMutations(mutable => { ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId; 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 mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId }); const status = getStatus(state, { id: props.params.statusId });
const ancestorsIds = status ? getAncestorsIds(state, { id: status.get('in_reply_to_id') }) : ImmutableList();
let ancestorsIds = Immutable.List(); const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList();
let descendantsIds = Immutable.List(); const referencesIds = status ? getReferencesIds(state, { id: status.get('id') }) : ImmutableList();
const id = status ? getProper(status).get('id') : null;
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
}
return { return {
status, status,
ancestorsIds, ancestorsIds: ancestorsIds.concat(referencesIds).sortBy(id => id),
descendantsIds, descendantsIds,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }), pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
emojiMap: customEmojiMap(state), 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, status: ImmutablePropTypes.map,
ancestorsIds: ImmutablePropTypes.list, ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list,
referenced: PropTypes.bool,
contextReferenced: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
askReplyConfirmation: PropTypes.bool, askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -465,37 +475,34 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(removeEmojiReaction(status)); 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 { status, ancestorsIds, descendantsIds } = this.props;
const statusIds = ImmutableList([status.get('id')]);
if (id === status.get('id')) { return ImmutableList().concat(ancestorsIds, statusIds, descendantsIds).indexOf(id);
this._selectChild(ancestorsIds.size - 1, true); }
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) { handleMoveUp = id => {
index = descendantsIds.indexOf(id); const index = this.getCurrentStatusIndex(id);
this._selectChild(ancestorsIds.size + index, true);
} else { if (index !== -1) {
this._selectChild(index - 1, true); return this._selectChild(index - 1, true);
}
} }
} }
handleMoveDown = id => { handleMoveDown = id => {
const { status, ancestorsIds, descendantsIds } = this.props; const index = this.getCurrentStatusIndex(id);
if (id === status.get('id')) { if (index !== -1) {
this._selectChild(ancestorsIds.size + 1, false); return this._selectChild(index + 1, true);
} 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);
}
} }
} }
@ -556,7 +563,7 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; 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; const { fullscreen } = this.state;
if (status === null) { if (status === null) {
@ -576,6 +583,8 @@ class Status extends ImmutablePureComponent {
descendants = <div>{this.renderChildren(descendantsIds)}</div>; 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 = { const handlers = {
moveUp: this.handleHotkeyMoveUp, moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown, moveDown: this.handleHotkeyMoveDown,
@ -599,15 +608,23 @@ class Status extends ImmutablePureComponent {
)} )}
/> />
<DetailedHeaderContaier statusId={status.get('id')} />
<ScrollContainer scrollKey='thread'> <ScrollContainer scrollKey='thread'>
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}> <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
{ancestors} {ancestors}
<HotKeys handlers={handlers}> <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 <DetailedStatus
key={`details-${status.get('id')}`} key={`details-${status.get('id')}`}
status={status} status={status}
referenced={referenced}
contextReferenced={contextReferenced}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia} onOpenMedia={this.handleOpenMedia}
onOpenVideoQuote={this.handleOpenVideoQuote} onOpenVideoQuote={this.handleOpenVideoQuote}
@ -628,6 +645,8 @@ class Status extends ImmutablePureComponent {
<ActionBar <ActionBar
key={`action-bar-${status.get('id')}`} key={`action-bar-${status.get('id')}`}
status={status} status={status}
referenced={referenced}
contextReferenced={contextReferenced}
onReply={this.handleReplyClick} onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick} onFavourite={this.handleFavouriteClick}
onReblog={this.handleReblogClick} onReblog={this.handleReblogClick}
@ -649,6 +668,8 @@ class Status extends ImmutablePureComponent {
onEmbed={this.handleEmbed} onEmbed={this.handleEmbed}
addEmojiReaction={this.handleAddEmojiReaction} addEmojiReaction={this.handleAddEmojiReaction}
removeEmojiReaction={this.handleRemoveEmojiReaction} removeEmojiReaction={this.handleRemoveEmojiReaction}
onAddReference={this.handleAddReference}
onRemoveReference={this.handleRemoveReference}
/> />
</div> </div>
</HotKeys> </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 { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Footer from 'mastodon/features/picture_in_picture/components/footer'; 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 }) => ({ const makeMapStateToProps = () => {
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), 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 { class AudioModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -24,6 +40,14 @@ class AudioModal extends ImmutablePureComponent {
onChangeBackgroundColor: PropTypes.func.isRequired, onChangeBackgroundColor: PropTypes.func.isRequired,
}; };
handleAddReference = (id, change) => {
this.props.dispatch(addReference(id, change));
}
handleRemoveReference = (id) => {
this.props.dispatch(removeReference(id));
}
render () { render () {
const { media, accountStaticAvatar, statusId, onClose } = this.props; const { media, accountStaticAvatar, statusId, onClose } = this.props;
const options = this.props.options || {}; 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 Footer from 'mastodon/features/picture_in_picture/components/footer';
import { getAverageFromBlurhash } from 'mastodon/blurhash'; import { getAverageFromBlurhash } from 'mastodon/blurhash';
export default class VideoModal extends ImmutablePureComponent { export default
class VideoModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,

View file

@ -26,6 +26,7 @@ import PictureInPicture from 'mastodon/features/picture_in_picture';
import { import {
Compose, Compose,
Status, Status,
StatusReferences,
GettingStarted, GettingStarted,
KeyboardShortcuts, KeyboardShortcuts,
PublicTimeline, PublicTimeline,
@ -50,6 +51,7 @@ import {
FavouritedStatuses, FavouritedStatuses,
BookmarkedStatuses, BookmarkedStatuses,
EmojiReactionedStatuses, EmojiReactionedStatuses,
ReferredByStatuses,
ListTimeline, ListTimeline,
Blocks, Blocks,
DomainBlocks, DomainBlocks,
@ -188,9 +190,11 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/new' component={Compose} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} 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/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} 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/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='/statuses/:statusId/mentions' component={Mentions} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} 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'); return import(/* webpackChunkName: "features/status" */'../../status');
} }
export function StatusReferences () {
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
}
export function GettingStarted () { export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); 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'); 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 () { export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks'); return import(/* webpackChunkName: "features/blocks" */'../../blocks');
} }
@ -142,6 +150,10 @@ export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
} }
export function ThumbnailGallery () {
return import(/* webpackChunkName: "status/thumbnail_gallery" */'../../../components/thumbnail_gallery');
}
export function MediaGallery () { export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); 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 unsubscribeModal = getMeta('unsubscribe_modal');
export const boostModal = getMeta('boost_modal'); export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_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 me = getMeta('me');
export const searchEnabled = getMeta('search_enabled'); export const searchEnabled = getMeta('search_enabled');
export const invitesEnabled = getMeta('invites_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 show_reply_tree_button = getMeta('show_reply_tree_button');
export const disable_joke_appearance = getMeta('disable_joke_appearance'); export const disable_joke_appearance = getMeta('disable_joke_appearance');
export const new_features_policy = getMeta('new_features_policy'); 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; export default initialState;

View file

@ -146,6 +146,8 @@
"confirmations.block.block_and_report": "Block & Report", "confirmations.block.block_and_report": "Block & Report",
"confirmations.block.confirm": "Block", "confirmations.block.confirm": "Block",
"confirmations.block.message": "Are you sure you want to block {name}?", "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.confirm": "Delete",
"confirmations.delete.message": "Are you sure you want to delete this post?", "confirmations.delete.message": "Are you sure you want to delete this post?",
"confirmations.delete_circle.confirm": "Delete", "confirmations.delete_circle.confirm": "Delete",
@ -159,6 +161,8 @@
"confirmations.mute.confirm": "Mute", "confirmations.mute.confirm": "Mute",
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.", "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
"confirmations.mute.message": "Are you sure you want to mute {name}?", "confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.post_reference.confirm": "Post",
"confirmations.post_reference.message": "It contains references, do you want to post it?",
"confirmations.quote.confirm": "Quote", "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.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
"confirmations.redraft.confirm": "Delete & redraft", "confirmations.redraft.confirm": "Delete & redraft",
@ -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.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.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "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.confirm": "Unsubscribe",
"confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?", "confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?",
"conversation.delete": "Delete conversation", "conversation.delete": "Delete conversation",
@ -228,6 +234,7 @@
"empty_column.mutes": "You haven't muted any users yet.", "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.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.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.suggestions": "No one has suggestions yet.",
"empty_column.trends": "No one has trends 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.", "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.emoji_reaction": "{name} reactioned your post",
"notification.reblog": "{name} boosted your post", "notification.reblog": "{name} boosted your post",
"notification.status": "{name} just posted", "notification.status": "{name} just posted",
"notification.status_reference": "{name} referenced your post",
"notifications.clear": "Clear notifications", "notifications.clear": "Clear notifications",
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
"notifications.column_settings.alert": "Desktop notifications", "notifications.column_settings.alert": "Desktop notifications",
@ -419,6 +427,7 @@
"notifications.column_settings.show": "Show in column", "notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound", "notifications.column_settings.sound": "Play sound",
"notifications.column_settings.status": "New posts:", "notifications.column_settings.status": "New posts:",
"notifications.column_settings.status_reference": "Status reference:",
"notifications.column_settings.unread_markers.category": "Unread notification markers", "notifications.column_settings.unread_markers.category": "Unread notification markers",
"notifications.filter.all": "All", "notifications.filter.all": "All",
"notifications.filter.boosts": "Boosts", "notifications.filter.boosts": "Boosts",
@ -428,6 +437,7 @@
"notifications.filter.polls": "Poll results", "notifications.filter.polls": "Poll results",
"notifications.filter.emoji_reactions": "Reactions", "notifications.filter.emoji_reactions": "Reactions",
"notifications.filter.statuses": "Updates from people you follow", "notifications.filter.statuses": "Updates from people you follow",
"notifications.filter.status_references": "Status references",
"notifications.grant_permission": "Grant permission.", "notifications.grant_permission": "Grant permission.",
"notifications.group": "{count} notifications", "notifications.group": "{count} notifications",
"notifications.mark_as_read": "Mark every notification as read", "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.long": "Visible for all, but not in public timelines",
"privacy.unlisted.short": "Unlisted", "privacy.unlisted.short": "Unlisted",
"quote_indicator.cancel": "Cancel", "quote_indicator.cancel": "Cancel",
"reference_stack.header": "References",
"reference_stack.unselect": "Unselecting a post",
"refresh": "Refresh", "refresh": "Refresh",
"regeneration_indicator.label": "Loading…", "regeneration_indicator.label": "Loading…",
"regeneration_indicator.sublabel": "Your home feed is being prepared!", "regeneration_indicator.sublabel": "Your home feed is being prepared!",
@ -493,6 +505,7 @@
"status.block": "Block @{name}", "status.block": "Block @{name}",
"status.bookmark": "Bookmark", "status.bookmark": "Bookmark",
"status.cancel_reblog_private": "Unboost", "status.cancel_reblog_private": "Unboost",
"status.cancel_reference": "Remove reference",
"status.cannot_quote": "This post cannot be quoted", "status.cannot_quote": "This post cannot be quoted",
"status.cannot_reblog": "This post cannot be boosted", "status.cannot_reblog": "This post cannot be boosted",
"status.copy": "Copy link to post", "status.copy": "Copy link to post",
@ -523,6 +536,8 @@
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",
"status.reference": "Reference",
"status.referred_by": "Referred",
"status.remove_bookmark": "Remove bookmark", "status.remove_bookmark": "Remove bookmark",
"status.reply": "Reply", "status.reply": "Reply",
"status.replyAll": "Reply to thread", "status.replyAll": "Reply to thread",
@ -538,7 +553,9 @@
"status.show_more_all": "Show more for all", "status.show_more_all": "Show more for all",
"status.show_poll": "Show poll", "status.show_poll": "Show poll",
"status.show_reblogs": "Show boosted users", "status.show_reblogs": "Show boosted users",
"status.show_referred_by_statuses": "Show referred by statuses",
"status.show_thread": "Show thread", "status.show_thread": "Show thread",
"status.thread_with_references": "Thread",
"status.uncached_media_warning": "Not available", "status.uncached_media_warning": "Not available",
"status.unlisted_quote": "Unlisted quote", "status.unlisted_quote": "Unlisted quote",
"status.unmute_conversation": "Unmute conversation", "status.unmute_conversation": "Unmute conversation",
@ -553,6 +570,12 @@
"tabs_bar.local_timeline": "Local", "tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"tabs_bar.search": "Search", "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.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left", "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
@ -598,5 +621,9 @@
"video.mute": "Mute sound", "video.mute": "Mute sound",
"video.pause": "Pause", "video.pause": "Pause",
"video.play": "Play", "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.block_and_report": "ブロックし通報",
"confirmations.block.confirm": "ブロック", "confirmations.block.confirm": "ブロック",
"confirmations.block.message": "本当に{name}さんをブロックしますか?", "confirmations.block.message": "本当に{name}さんをブロックしますか?",
"confirmations.clear.confirm": "すべての参照を解除",
"confirmations.clear.message": "本当にすべての参照を解除しますか?",
"confirmations.delete.confirm": "削除", "confirmations.delete.confirm": "削除",
"confirmations.delete.message": "本当に削除しますか?", "confirmations.delete.message": "本当に削除しますか?",
"confirmations.delete_circle.confirm": "削除", "confirmations.delete_circle.confirm": "削除",
@ -159,6 +161,8 @@
"confirmations.mute.confirm": "ミュート", "confirmations.mute.confirm": "ミュート",
"confirmations.mute.explanation": "これにより相手の投稿と返信は見えなくなりますが、相手はあなたをフォローし続け投稿を見ることができます。", "confirmations.mute.explanation": "これにより相手の投稿と返信は見えなくなりますが、相手はあなたをフォローし続け投稿を見ることができます。",
"confirmations.mute.message": "本当に{name}さんをミュートしますか?", "confirmations.mute.message": "本当に{name}さんをミュートしますか?",
"confirmations.post_reference.confirm": "投稿",
"confirmations.post_reference.message": "参照を含んでいますが、投稿しますか?",
"confirmations.quote.confirm": "引用", "confirmations.quote.confirm": "引用",
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?", "confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.redraft.confirm": "削除して下書きに戻す", "confirmations.redraft.confirm": "削除して下書きに戻す",
@ -167,6 +171,8 @@
"confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?", "confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
"confirmations.unfollow.confirm": "フォロー解除", "confirmations.unfollow.confirm": "フォロー解除",
"confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?", "confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?",
"confirmations.unselect.confirm": "選択解除",
"confirmations.unselect.message": "本当に参照の選択を解除しますか?",
"confirmations.unsubscribe.confirm": "購読解除", "confirmations.unsubscribe.confirm": "購読解除",
"confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?", "confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?",
"conversation.delete": "会話を削除", "conversation.delete": "会話を削除",
@ -222,12 +228,12 @@
"empty_column.hashtag": "このハッシュタグはまだ使われていません。", "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
"empty_column.home": "ホームタイムラインはまだ空っぽです。誰かフォローして埋めてみましょう。 {suggestions}", "empty_column.home": "ホームタイムラインはまだ空っぽです。誰かフォローして埋めてみましょう。 {suggestions}",
"empty_column.home.suggestions": "おすすめを見る", "empty_column.home.suggestions": "おすすめを見る",
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
"empty_column.limited": "まだ誰からも公開範囲が限定された投稿を受け取っていません。", "empty_column.limited": "まだ誰からも公開範囲が限定された投稿を受け取っていません。",
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。", "empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
"empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。", "empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。",
"empty_column.mutes": "まだ誰もミュートしていません。", "empty_column.mutes": "まだ誰もミュートしていません。",
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。", "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
"empty_column.referred_by_statuses": "まだ、参照している投稿はありません。誰かが投稿を参照すると、ここに表示されます。",
"empty_column.suggestions": "まだおすすめできるユーザーがいません。", "empty_column.suggestions": "まだおすすめできるユーザーがいません。",
"empty_column.trends": "まだ何もトレンドがありません。", "empty_column.trends": "まだ何もトレンドがありません。",
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう", "empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう",
@ -403,6 +409,7 @@
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました", "notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました", "notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.status": "{name}さんが投稿しました", "notification.status": "{name}さんが投稿しました",
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
"notifications.clear": "通知を消去", "notifications.clear": "通知を消去",
"notifications.clear_confirmation": "本当に通知を消去しますか?", "notifications.clear_confirmation": "本当に通知を消去しますか?",
"notifications.column_settings.alert": "デスクトップ通知", "notifications.column_settings.alert": "デスクトップ通知",
@ -420,6 +427,7 @@
"notifications.column_settings.show": "カラムに表示", "notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.sound": "通知音を再生", "notifications.column_settings.sound": "通知音を再生",
"notifications.column_settings.status": "新しい投稿:", "notifications.column_settings.status": "新しい投稿:",
"notifications.column_settings.status_reference": "投稿の参照:",
"notifications.column_settings.unread_markers.category": "未読マーカー", "notifications.column_settings.unread_markers.category": "未読マーカー",
"notifications.filter.all": "すべて", "notifications.filter.all": "すべて",
"notifications.filter.boosts": "ブースト", "notifications.filter.boosts": "ブースト",
@ -429,6 +437,7 @@
"notifications.filter.polls": "アンケート結果", "notifications.filter.polls": "アンケート結果",
"notifications.filter.emoji_reactions": "リアクション", "notifications.filter.emoji_reactions": "リアクション",
"notifications.filter.statuses": "フォローしている人の新着情報", "notifications.filter.statuses": "フォローしている人の新着情報",
"notifications.filter.status_references": "投稿の参照",
"notifications.grant_permission": "権限の付与", "notifications.grant_permission": "権限の付与",
"notifications.group": "{count} 件の通知", "notifications.group": "{count} 件の通知",
"notifications.mark_as_read": "すべて既読にする", "notifications.mark_as_read": "すべて既読にする",
@ -461,6 +470,8 @@
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示", "privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
"privacy.unlisted.short": "未収載", "privacy.unlisted.short": "未収載",
"quote_indicator.cancel": "キャンセル", "quote_indicator.cancel": "キャンセル",
"reference_stack.header": "参照",
"reference_stack.unselect": "投稿を選択解除",
"refresh": "更新", "refresh": "更新",
"regeneration_indicator.label": "読み込み中…", "regeneration_indicator.label": "読み込み中…",
"regeneration_indicator.sublabel": "ホームタイムラインは準備中です!", "regeneration_indicator.sublabel": "ホームタイムラインは準備中です!",
@ -494,6 +505,7 @@
"status.block": "@{name}さんをブロック", "status.block": "@{name}さんをブロック",
"status.bookmark": "ブックマーク", "status.bookmark": "ブックマーク",
"status.cancel_reblog_private": "ブースト解除", "status.cancel_reblog_private": "ブースト解除",
"status.cancel_reference": "参照解除",
"status.cannot_quote": "この投稿は引用できません", "status.cannot_quote": "この投稿は引用できません",
"status.cannot_reblog": "この投稿はブーストできません", "status.cannot_reblog": "この投稿はブーストできません",
"status.copy": "投稿へのリンクをコピー", "status.copy": "投稿へのリンクをコピー",
@ -524,6 +536,8 @@
"status.reblogged_by": "{name}さんがブースト", "status.reblogged_by": "{name}さんがブースト",
"status.reblogs.empty": "まだ誰もブーストしていません。ブーストされるとここに表示されます。", "status.reblogs.empty": "まだ誰もブーストしていません。ブーストされるとここに表示されます。",
"status.redraft": "削除して下書きに戻す", "status.redraft": "削除して下書きに戻す",
"status.reference": "参照",
"status.referred_by": "参照",
"status.remove_bookmark": "ブックマークを削除", "status.remove_bookmark": "ブックマークを削除",
"status.reply": "返信", "status.reply": "返信",
"status.replyAll": "全員に返信", "status.replyAll": "全員に返信",
@ -539,7 +553,9 @@
"status.show_more_all": "全て見る", "status.show_more_all": "全て見る",
"status.show_poll": "アンケートを表示", "status.show_poll": "アンケートを表示",
"status.show_reblogs": "ブーストしたユーザーを表示", "status.show_reblogs": "ブーストしたユーザーを表示",
"status.show_referred_by_statuses": "参照している投稿を表示",
"status.show_thread": "スレッドを表示", "status.show_thread": "スレッドを表示",
"status.thread_with_references": "スレッド",
"status.uncached_media_warning": "利用できません", "status.uncached_media_warning": "利用できません",
"status.unlisted_quote": "未収載の引用", "status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除", "status.unmute_conversation": "会話のミュートを解除",
@ -554,6 +570,12 @@
"tabs_bar.local_timeline": "ローカル", "tabs_bar.local_timeline": "ローカル",
"tabs_bar.notifications": "通知", "tabs_bar.notifications": "通知",
"tabs_bar.search": "検索", "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.days": "残り{number}日",
"time_remaining.hours": "残り{number}時間", "time_remaining.hours": "残り{number}時間",
"time_remaining.minutes": "残り{number}分", "time_remaining.minutes": "残り{number}分",
@ -599,5 +621,9 @@
"video.mute": "ミュート", "video.mute": "ミュート",
"video.pause": "一時停止", "video.pause": "一時停止",
"video.play": "再生", "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_SCHEDULED_CHANGE,
COMPOSE_EXPIRES_CHANGE, COMPOSE_EXPIRES_CHANGE,
COMPOSE_EXPIRES_ACTION_CHANGE, COMPOSE_EXPIRES_ACTION_CHANGE,
COMPOSE_REFERENCE_ADD,
COMPOSE_REFERENCE_REMOVE,
COMPOSE_REFERENCE_RESET,
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines'; import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { REDRAFT } from '../actions/statuses'; 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 uuid from '../uuid';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
@ -103,6 +106,8 @@ const initialState = ImmutableMap({
scheduled: null, scheduled: null,
expires: null, expires: null,
expires_action: 'mark', expires_action: 'mark',
references: ImmutableSet(),
context_references: ImmutableSet(),
}); });
const initialPoll = ImmutableMap({ const initialPoll = ImmutableMap({
@ -155,6 +160,8 @@ const clearAll = state => {
map.set('scheduled', null); map.set('scheduled', null);
map.set('expires', null); map.set('expires', null);
map.set('expires_action', 'mark'); 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('scheduled', null);
map.set('expires', null); map.set('expires', null);
map.set('expires_action', 'mark'); map.set('expires_action', 'mark');
map.update('context_references', set => set.clear().concat(action.context_references));
if (action.status.get('spoiler_text').length > 0) { if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true); map.set('spoiler', true);
@ -397,6 +405,7 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null); map.set('scheduled', null);
map.set('expires', null); map.set('expires', null);
map.set('expires_action', 'mark'); 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) { if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true); map.set('spoiler', true);
@ -425,6 +434,10 @@ export default function compose(state = initialState, action) {
map.set('scheduled', null); map.set('scheduled', null);
map.set('expires', null); map.set('expires', null);
map.set('expires_action', 'mark'); 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: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); 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('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.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.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) { if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true); map.set('spoiler', true);
@ -583,6 +598,12 @@ export default function compose(state = initialState, action) {
return state.set('expires', action.value); return state.set('expires', action.value);
case COMPOSE_EXPIRES_ACTION_CHANGE: case COMPOSE_EXPIRES_ACTION_CHANGE:
return state.set('expires_action', action.value); 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: default:
return state; return state;
} }

View file

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

View file

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

View file

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

View file

@ -37,6 +37,12 @@ import {
UNPIN_SUCCESS, UNPIN_SUCCESS,
} from '../actions/interactions'; } from '../actions/interactions';
const initialListState = ImmutableMap({
next: null,
isLoading: false,
items: ImmutableList(),
});
const initialState = ImmutableMap({ const initialState = ImmutableMap({
favourites: ImmutableMap({ favourites: ImmutableMap({
next: null, 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; border-top: 0;
} }
.icon-with-badge__badge { .icon-with-badge__badge,
.status__thread_mark {
border-color: $white; border-color: $white;
} }
@ -748,7 +749,8 @@ html {
} }
.public-layout { .public-layout {
.account__section-headline { .account__section-headline,
.status__section-headline {
border: 1px solid lighten($ui-base-color, 8%); border: 1px solid lighten($ui-base-color, 8%);
@media screen and (max-width: $no-gap-breakpoint) { @media screen and (max-width: $no-gap-breakpoint) {
@ -837,7 +839,8 @@ html {
} }
.notification__filter-bar button.active::after, .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; border-color: transparent transparent $white;
} }

View file

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

View file

@ -983,7 +983,7 @@
.status__content__read-more-button { .status__content__read-more-button {
display: block; display: block;
font-size: 15px; font-size: 12px;
line-height: 20px; line-height: 20px;
color: lighten($ui-highlight-color, 8%); color: lighten($ui-highlight-color, 8%);
border: 0; border: 0;
@ -1184,6 +1184,7 @@
.status__relative-time, .status__relative-time,
.status__visibility-icon, .status__visibility-icon,
.status__thread_mark,
.status__expiration-time, .status__expiration-time,
.notification__relative_time { .notification__relative_time {
color: $dark-text-color; color: $dark-text-color;
@ -1213,6 +1214,27 @@
color: $dark-text-color; 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 { .status__info .status__display-name {
display: block; display: block;
max-width: 100%; max-width: 100%;
@ -1327,6 +1349,7 @@
.detailed-status { .detailed-status {
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
padding: 14px 10px; padding: 14px 10px;
position: relative;
&--flex { &--flex {
display: flex; display: flex;
@ -1360,6 +1383,28 @@
.audio-player { .audio-player {
margin-top: 8px; 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 { .detailed-status__meta {
@ -1385,6 +1430,7 @@
.detailed-status__favorites, .detailed-status__favorites,
.detailed-status__emoji_reactions, .detailed-status__emoji_reactions,
.detailed-status__status_referred_by,
.detailed-status__reblogs { .detailed-status__reblogs {
display: inline-block; display: inline-block;
font-weight: 500; font-weight: 500;
@ -2868,7 +2914,7 @@ a.account__display-name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100% - 10px); height: calc(100% - 10px);
overflow-y: hidden; overflow-y: auto;
.navigation-bar { .navigation-bar {
padding-top: 20px; padding-top: 20px;
@ -2882,7 +2928,7 @@ a.account__display-name {
} }
.compose-form { .compose-form {
flex: 1; flex: 1 0 auto;
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
flex-direction: column; 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, .notification__filter-bar,
.account__section-headline, .account__section-headline,
.status__section-headline,
.detailed-status__section-headline,
.status-reactioned__section-headline { .status-reactioned__section-headline {
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%);
@ -7931,7 +7985,8 @@ noscript {
} }
.notification, .notification,
.status__wrapper { .status__wrapper,
.mini-status__wrapper {
position: relative; position: relative;
&.unread { &.unread {
@ -7947,6 +8002,29 @@ noscript {
pointer-events: none; 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 { .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; border-radius: 4px 4px 0 0;
@media screen and (max-width: $no-gap-breakpoint) { @media screen and (max-width: $no-gap-breakpoint) {

View file

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

View file

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

View file

@ -72,6 +72,12 @@ class ActivityPub::TagManager
account_status_replies_url(target.account, target, page_params) account_status_replies_url(target.account, target, page_params)
end 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 # Primary audience of a status
# Public statuses go out to primarily the public collection # Public statuses go out to primarily the public collection
# Unlisted and private statuses go out primarily to the followers collection # Unlisted and private statuses go out primarily to the followers collection

View file

@ -59,7 +59,7 @@ class EntityCache
account account
end 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)) return Rails.cache.read(to_key(:holding_status, url)) if Rails.cache.exist?(to_key(:holding_status, url))
status = begin status = begin
@ -72,11 +72,13 @@ class EntityCache
nil nil
end 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 end
def to_key(type, *ids) 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])) filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
when :mentions when :mentions
filter_from_mentions?(status, receiver.id) filter_from_mentions?(status, receiver.id)
when :status_references
filter_from_status_references?(status, receiver.id)
else else
false false
end end
@ -425,6 +427,28 @@ class FeedManager
should_filter should_filter
end 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 # Check if status should not be added to the list feed
# @param [Status] status # @param [Status] status
# @param [List] list # @param [List] list

View file

@ -27,6 +27,7 @@ class Formatter
unless status.local? unless status.local?
html = reformat(raw_content) html = reformat(raw_content)
html = apply_inner_link(html) 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 = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = nyaize_html(html) if options[:nyaize] html = nyaize_html(html) if options[:nyaize]
return html.html_safe # rubocop:disable Rails/OutputSafety 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 = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
html = simple_format(html, {}, sanitize: false) html = simple_format(html, {}, sanitize: false)
html = quotify(html, status) if status.quote? && !options[:escape_quotify] html = 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 = nyaize_html(html) if options[:nyaize]
html = html.delete("\n") html = html.delete("\n")
@ -68,6 +70,7 @@ class Formatter
return status.text if status.local? return status.text if status.local?
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" } text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
text = remove_reference_link(text)
strip_tags(text) strip_tags(text)
end end
@ -212,6 +215,12 @@ class Formatter
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{link}</span>\\1") html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{link}</span>\\1")
end 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) def nyaize_html(html)
inside_anchor = false inside_anchor = false
@ -293,8 +302,9 @@ class Formatter
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me] html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
status, account = url_to_holding_status_and_account(url.normalize.to_s) status = url_to_holding_status(url.normalize.to_s)
account = url_to_holding_account(url.normalize.to_s) if status.nil? account = status&.account
account = url_to_holding_account(url.normalize.to_s) if status.nil?
if status.present? && account.present? if status.present? && account.present?
html_attrs[:class] = class_append(html_attrs[:class], ['status-url-link']) html_attrs[:class] = class_append(html_attrs[:class], ['status-url-link'])
@ -315,8 +325,9 @@ class Formatter
def apply_inner_link(html) def apply_inner_link(html)
doc = Nokogiri::HTML.parse(html, nil, 'utf-8') doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
doc.css('a').map do |x| doc.css('a').map do |x|
status, account = url_to_holding_status_and_account(x['href']) status = url_to_holding_status(x['href'])
account = url_to_holding_account(x['href']) if status.nil? account = status&.account
account = url_to_holding_account(x['href']) if status.nil?
if status.present? && account.present? if status.present? && account.present?
x.add_class('status-url-link') x.add_class('status-url-link')
@ -333,6 +344,44 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety html.html_safe # rubocop:disable Rails/OutputSafety
end 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) def url_to_holding_account(url)
url = url.split('#').first url = url.split('#').first
@ -341,12 +390,12 @@ class Formatter
EntityCache.instance.holding_account(url) EntityCache.instance.holding_account(url)
end end
def url_to_holding_status_and_account(url) def url_to_holding_status(url)
url = url.split('#').first url = url.split('#').first
return if url.nil? return if url.nil?
EntityCache.instance.holding_status_and_account(url) EntityCache.instance.holding_status(url)
end end
def link_to_mention(entity, linkable_accounts, options = {}) def link_to_mention(entity, linkable_accounts, options = {})

View file

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

View file

@ -38,6 +38,9 @@ class UserSettingsDecorator
user.settings['unsubscribe_modal'] = unsubscribe_modal_preference if change?('setting_unsubscribe_modal') 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['boost_modal'] = boost_modal_preference if change?('setting_boost_modal')
user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_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['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['display_media'] = display_media_preference if change?('setting_display_media')
user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers') 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['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['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') 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 def merged_notification_emails
user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
@ -107,6 +112,18 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_delete_modal' boolean_cast_setting 'setting_delete_modal'
end 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 def system_font_ui_preference
boolean_cast_setting 'setting_system_font_ui' boolean_cast_setting 'setting_system_font_ui'
end end
@ -251,6 +268,14 @@ class UserSettingsDecorator
settings['setting_theme_instance_ticker'] settings['setting_theme_instance_ticker']
end 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) def boolean_cast_setting(key)
ActiveModel::Type::Boolean.new.cast(settings[key]) ActiveModel::Type::Boolean.new.cast(settings[key])
end end

View file

@ -80,6 +80,19 @@ class NotificationMailer < ApplicationMailer
end end
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) def digest(recipient, **opts)
return unless recipient.user.functional? return unless recipient.user.functional?

View file

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

View file

@ -4,27 +4,51 @@ module StatusThreadingConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
def ancestors(limit, account = nil) 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 end
def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil) 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) find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account, promote: true)
end 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) def self_replies(limit)
account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit) account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit)
end end
private 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}" key = "ancestors:#{id}"
ancestors = Rails.cache.fetch(key) ancestors = Rails.cache.fetch(key)
if ancestors.nil? || ancestors[:limit] < limit if ancestors.nil? || ancestors[:limit] < limit
ids = ancestor_statuses(limit).pluck(:id).reverse! ancestor_statuses(limit).pluck(:id, :account_id).tap do |ids_account_ids|
Rails.cache.write key, limit: limit, ids: ids Rails.cache.write key, limit: limit, ids: ids_account_ids
ids end
else else
ancestors[:ids].last(limit) ancestors[:ids].last(limit)
end end
@ -32,26 +56,26 @@ module StatusThreadingConcern
def ancestor_statuses(limit) def ancestor_statuses(limit)
Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: 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 ( AS (
SELECT id, in_reply_to_id, ARRAY[id] SELECT id, account_id, in_reply_to_id, ARRAY[id]
FROM statuses FROM statuses
WHERE id = :id WHERE id = :id
UNION ALL 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 FROM search_tree
JOIN statuses ON statuses.id = search_tree.in_reply_to_id JOIN statuses ON statuses.id = search_tree.in_reply_to_id
WHERE NOT statuses.id = ANY(path) WHERE NOT statuses.id = ANY(path)
) )
SELECT id SELECT id, account_id
FROM search_tree FROM search_tree
ORDER BY path ORDER BY path
LIMIT :limit LIMIT :limit
SQL SQL
end end
def descendant_ids(limit, max_child_id, since_child_id, depth) def descendant_ids_account_ids(limit, max_child_id, since_child_id, depth)
descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id) @descendant_statuses ||= descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id, :account_id)
end end
def descendant_statuses(limit, max_child_id, since_child_id, depth) def descendant_statuses(limit, max_child_id, since_child_id, depth)
@ -60,18 +84,18 @@ module StatusThreadingConcern
limit += 1 if limit.present? 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]) 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 ( AS (
SELECT id, ARRAY[id] SELECT id, account_id, ARRAY[id]
FROM statuses FROM statuses
WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE) WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
UNION ALL UNION ALL
SELECT statuses.id, path || statuses.id SELECT statuses.id, statuses.account_id, path || statuses.id
FROM search_tree FROM search_tree
JOIN statuses ON statuses.in_reply_to_id = search_tree.id 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) WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
) )
SELECT id SELECT id, account_id
FROM search_tree FROM search_tree
ORDER BY path ORDER BY path
LIMIT :limit LIMIT :limit
@ -81,12 +105,7 @@ module StatusThreadingConcern
end end
def find_statuses_from_tree_path(ids, account, promote: false) def find_statuses_from_tree_path(ids, account, promote: false)
statuses = Status.with_accounts(ids).to_a statuses = Status.permitted_statuses_from_ids(ids, account)
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? }
# Order ancestors/descendants by tree path # Order ancestors/descendants by tree path
statuses.sort_by! { |status| ids.index(status.id) } statuses.sort_by! { |status| ids.index(status.id) }
@ -113,32 +132,4 @@ module StatusThreadingConcern
arr arr
end 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 end

View file

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

View file

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

View file

@ -79,6 +79,11 @@ class Status < ApplicationRecord
has_and_belongs_to_many :tags has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards has_and_belongs_to_many :preview_cards
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 :notification, as: :activity, dependent: :destroy
has_one :status_stat, inverse_of: :status has_one :status_stat, inverse_of: :status
has_one :poll, inverse_of: :status, dependent: :destroy has_one :poll, inverse_of: :status, dependent: :destroy
@ -134,6 +139,7 @@ class Status < ApplicationRecord
:tags, :tags,
:preview_cards, :preview_cards,
:preloadable_poll, :preloadable_poll,
references: { account: :account_stat },
account: [:account_stat, :user], account: [:account_stat, :user],
active_mentions: { account: :account_stat }, active_mentions: { account: :account_stat },
reblog: [ reblog: [
@ -145,6 +151,7 @@ class Status < ApplicationRecord
:status_stat, :status_stat,
:status_expire, :status_expire,
:preloadable_poll, :preloadable_poll,
references: { account: :account_stat },
account: [:account_stat, :user], account: [:account_stat, :user],
active_mentions: { account: :account_stat }, active_mentions: { account: :account_stat },
], ],
@ -165,12 +172,14 @@ class Status < ApplicationRecord
ids += reblogs.where(account: Account.local).pluck(:account_id) ids += reblogs.where(account: Account.local).pluck(:account_id)
ids += bookmarks.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 += emoji_reactions.where(account: Account.local).pluck(:account_id)
ids += referred_by_statuses.where(account: Account.local).pluck(:account_id)
else else
ids += preloaded.mentions[id] || [] ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || [] ids += preloaded.favourites[id] || []
ids += preloaded.reblogs[id] || [] ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || [] ids += preloaded.bookmarks[id] || []
ids += preloaded.emoji_reactions[id] || [] ids += preloaded.emoji_reactions[id] || []
ids += preloaded.status_references[id] || []
end end
ids.uniq ids.uniq
@ -246,6 +255,10 @@ class Status < ApplicationRecord
public_visibility? || unlisted_visibility? public_visibility? || unlisted_visibility?
end end
def public_safety?
distributable? && (!with_media? || non_sensitive_with_media?) && !account.silenced? && !account.suspended?
end
def sign? def sign?
distributable? || limited_visibility? distributable? || limited_visibility?
end end
@ -303,6 +316,14 @@ class Status < ApplicationRecord
status_stat&.emoji_reactions_count || 0 status_stat&.emoji_reactions_count || 0
end 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) def grouped_emoji_reactions(account = nil)
(Oj.load(status_stat&.emoji_reactions_cache || '', mode: :strict) || []).tap do |emoji_reactions| (Oj.load(status_stat&.emoji_reactions_cache || '', mode: :strict) || []).tap do |emoji_reactions|
if account.present? if account.present?
@ -326,6 +347,16 @@ class Status < ApplicationRecord
end end
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) def increment_count!(key)
update_status_stat!(key => public_send(key) + 1) update_status_stat!(key => public_send(key) + 1)
end 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 } 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 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) def reload_stale_associations!(cached_items)
account_ids = [] account_ids = []
@ -402,6 +461,16 @@ class Status < ApplicationRecord
end end
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) def permitted_for(target_account, account)
visibility = [:public, :unlisted] 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 # Table name: status_stats
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# status_id :bigint(8) not null # status_id :bigint(8) not null
# replies_count :bigint(8) default(0), not null # replies_count :bigint(8) default(0), not null
# reblogs_count :bigint(8) default(0), not null # reblogs_count :bigint(8) default(0), not null
# favourites_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_count :bigint(8) default(0), not null
# emoji_reactions_cache :string default(""), not null # emoji_reactions_cache :string default(""), not null
# created_at :datetime not null # status_references_count :bigint(8) default(0), not null
# updated_at :datetime 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 class StatusStat < ApplicationRecord

View file

@ -134,6 +134,8 @@ class User < ApplicationRecord
:hide_statuses_count, :hide_following_count, :hide_followers_count, :disable_joke_appearance, :hide_statuses_count, :hide_following_count, :hide_followers_count, :disable_joke_appearance,
:new_features_policy, :new_features_policy,
:theme_instance_ticker, :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 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) limited? && owned? && (!reply? || record.thread.conversation_id != record.conversation_id)
end end
def subscribe?
return false unless show?
!unlisted? || owned? || following_author? || mention_exists?
end
private private
def requires_mention? def requires_mention?
@ -63,6 +69,10 @@ class StatusPolicy < ApplicationPolicy
author.id == current_account&.id author.id == current_account&.id
end end
def unlisted?
record.unlisted_visibility?
end
def private? def private?
record.private_visibility? record.private_visibility?
end end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::NoteSerializer < ActivityPub::Serializer 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, attributes :id, :type, :summary,
:in_reply_to, :published, :url, :in_reply_to, :published, :url,
@ -21,6 +21,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
has_many :virtual_tags, key: :tag has_many :virtual_tags, key: :tag
has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local? 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: :one_of, if: :poll_and_not_multiple?
has_many :poll_options, key: :any_of, if: :poll_and_multiple? has_many :poll_options, key: :any_of, if: :poll_and_multiple?
@ -66,6 +67,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
) )
end 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? def language?
object.language.present? object.language.present?
end end

View file

@ -2,7 +2,7 @@
class InitialStateSerializer < ActiveModel::Serializer class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts, :lists, attributes :meta, :compose, :accounts, :lists,
:media_attachments, :settings :media_attachments, :status_references, :settings
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer 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[:disable_joke_appearance] = object.current_account.user.setting_disable_joke_appearance
store[:new_features_policy] = object.current_account.user.setting_new_features_policy 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[: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 else
store[:auto_play_gif] = Setting.auto_play_gif store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media 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 } { accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
end end
def status_references
{ max_references: StatusReferenceValidator::LIMIT }
end
private private
def instance_presenter def instance_presenter

View file

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

View file

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

View file

@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end end
def status_type? 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 end
def reblog? def reblog?

View file

@ -8,6 +8,29 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
:provider_url, :html, :width, :height, :provider_url, :html, :width, :height,
:image, :embed_url, :blurhash :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 def image
object.image? ? full_asset_url(object.image.url(:original)) : nil object.image? ? full_asset_url(object.image.url(:original)) : nil
end 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, attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language, :sensitive, :spoiler_text, :visibility, :language,
:uri, :url, :replies_count, :reblogs_count, :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 :favourited, if: :current_user?
attribute :reblogged, if: :current_user? attribute :reblogged, if: :current_user?
@ -144,6 +146,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.grouped_emoji_reactions(current_user&.account) object.grouped_emoji_reactions(current_user&.account)
end end
def status_reference_ids
object.references.map(&:id).map(&:to_s)
end
def reblogged def reblogged
if instance_options && instance_options[:relationships] if instance_options && instance_options[:relationships]
instance_options[:relationships].reblogs_map[object.id] || false 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 def parse_urls
if @status.local? if @status.local?
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize } urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
urls.push(Addressable::URI.parse(references_short_account_status_url(@status.account, @status))) if @status.references.exists?
else else
html = Nokogiri::HTML(@status.text) html = Nokogiri::HTML(@status.text)
links = html.css(':not(.quote-inline) > a') links = html.css(':not(.quote-inline) > a')
@ -76,7 +77,12 @@ class FetchLinkCardService < BaseService
def bad_url?(uri) def bad_url?(uri)
# Avoid local instance URLs and invalid URLs # 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 end
# rubocop:disable Naming/MethodParameterName # rubocop:disable Naming/MethodParameterName

View file

@ -50,6 +50,10 @@ class NotifyService < BaseService
false false
end end
def blocked_status_reference?
FeedManager.instance.filter?(:status_references, @notification.status_reference.status, @recipient)
end
def following_sender? def following_sender?
return @following_sender if defined?(@following_sender) return @following_sender if defined?(@following_sender)
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account) @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) ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status, @circle) ProcessMentionsService.new.call(@status, @circle)
ProcessStatusReferenceService.new.call(@status, status_reference_ids: (@options[:status_reference_ids] || []) + [@quote_id], urls: @options[:status_reference_urls])
end end
def schedule_status! 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_unsubscribe_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
= f.input :setting_delete_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' %h4= t 'appearance.sensitive_content'

View file

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

View file

@ -82,6 +82,12 @@
.fields-group .fields-group
= f.input :setting_show_reply_tree_button, as: :boolean, wrapper: :with_label, fedibird_features: true = 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 -# .fields-group
-# = f.input :setting_show_target, as: :boolean, wrapper: :with_label -# = 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 }/ %meta{ name: 'description', content: description }/
= opengraph 'og:description', 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