Add status reference
This commit is contained in:
parent
e62da34738
commit
999e361892
118 changed files with 3316 additions and 324 deletions
|
@ -64,6 +64,11 @@ class StatusesIndex < Chewy::Index
|
|||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
crutch :status_references do |collection|
|
||||
data = ::StatusReference.joins(:status).where(target_status_id: collection.map(&:id)).where(status: { account: Account.local }).pluck(:target_status_id, :'status.account_id')
|
||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||
end
|
||||
|
||||
root date_detection: false do
|
||||
field :id, type: 'long'
|
||||
field :account_id, type: 'long'
|
||||
|
|
88
app/controllers/activitypub/references_controller.rb
Normal file
88
app/controllers/activitypub/references_controller.rb
Normal 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
|
|
@ -76,6 +76,8 @@ class Api::V1::NotificationsController < Api::BaseController
|
|||
val = params.permit(exclude_types: [])[:exclude_types] || []
|
||||
val = [val] unless val.is_a?(Enumerable)
|
||||
val = val << 'emoji_reaction' << 'status' unless new_notification_type_compatible?
|
||||
val = val << 'emoji_reaction' unless current_user&.setting_enable_reaction
|
||||
val = val << 'status_reference' unless current_user&.setting_enable_status_reference
|
||||
val.uniq
|
||||
end
|
||||
|
||||
|
|
|
@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
|
|||
def data_params
|
||||
return {} if params[:data].blank?
|
||||
|
||||
params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction])
|
||||
params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction, :status_reference])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -6,6 +6,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy]
|
||||
before_action :require_user!, except: [:show, :context]
|
||||
before_action :set_statuses, only: [:index]
|
||||
before_action :set_status, only: [:show, :context]
|
||||
before_action :set_thread, only: [:create]
|
||||
before_action :set_circle, only: [:create]
|
||||
|
@ -20,6 +21,11 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
# than this anyway
|
||||
CONTEXT_LIMIT = 4_096
|
||||
|
||||
def index
|
||||
@statuses = cache_collection(@statuses, Status)
|
||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
@status = cache_collection([@status], Status).first
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
|
@ -28,11 +34,19 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
def context
|
||||
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(CONTEXT_LIMIT, current_account)
|
||||
descendants_results = @status.descendants(CONTEXT_LIMIT, current_account)
|
||||
references_results = @status.thread_references(CONTEXT_LIMIT, current_account)
|
||||
|
||||
unless ActiveModel::Type::Boolean.new.cast(status_params[:with_reference])
|
||||
ancestors_results = (ancestors_results + references_results).sort_by {|status| status.id }
|
||||
references_results = []
|
||||
end
|
||||
|
||||
loaded_ancestors = cache_collection(ancestors_results, Status)
|
||||
loaded_descendants = cache_collection(descendants_results, Status)
|
||||
loaded_references = cache_collection(references_results, Status)
|
||||
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
|
||||
statuses = [@status] + @context.ancestors + @context.descendants
|
||||
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants, references: loaded_references )
|
||||
statuses = [@status] + @context.ancestors + @context.descendants + @context.references
|
||||
accountIds = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
|
||||
|
||||
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id)
|
||||
|
@ -54,13 +68,17 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
poll: status_params[:poll],
|
||||
idempotency: request.headers['Idempotency-Key'],
|
||||
with_rate_limit: true,
|
||||
quote_id: status_params[:quote_id].presence)
|
||||
quote_id: status_params[:quote_id].presence,
|
||||
status_reference_ids: (Array(status_params[:status_reference_ids]).uniq.map(&:to_i)),
|
||||
status_reference_urls: status_params[:status_reference_urls] || []
|
||||
)
|
||||
|
||||
|
||||
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
@status = Status.include_expired.where(account_id: current_account.id).find(params[:id])
|
||||
@status = Status.include_expired.where(account_id: current_account.id).find(status_params[:id])
|
||||
authorize @status, :destroy?
|
||||
|
||||
@status.discard
|
||||
|
@ -72,8 +90,12 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
|
||||
private
|
||||
|
||||
def set_statuses
|
||||
@statuses = Status.permitted_statuses_from_ids(status_ids, current_account)
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = Status.include_expired.find(params[:id])
|
||||
@status = Status.include_expired.find(status_params[:id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
|
@ -108,8 +130,17 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
@expires_at = status_params[:expires_at] || (status_params[:expires_in].blank? ? nil : (@scheduled_at || Time.now.utc) + status_params[:expires_in].to_i.seconds)
|
||||
end
|
||||
|
||||
def status_ids
|
||||
Array(statuses_params[:ids]).uniq.map(&:to_i)
|
||||
end
|
||||
|
||||
def statuses_params
|
||||
params.permit(ids: [])
|
||||
end
|
||||
|
||||
def status_params
|
||||
params.permit(
|
||||
:id,
|
||||
:status,
|
||||
:in_reply_to_id,
|
||||
:circle_id,
|
||||
|
@ -122,13 +153,16 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
:expires_in,
|
||||
:expires_at,
|
||||
:expires_action,
|
||||
:with_reference,
|
||||
media_ids: [],
|
||||
poll: [
|
||||
:multiple,
|
||||
:hide_totals,
|
||||
:expires_in,
|
||||
options: [],
|
||||
]
|
||||
],
|
||||
status_reference_ids: [],
|
||||
status_reference_urls: []
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ module StatusControllerConcern
|
|||
ANCESTORS_LIMIT = 40
|
||||
DESCENDANTS_LIMIT = 60
|
||||
DESCENDANTS_DEPTH_LIMIT = 20
|
||||
REFERENCES_LIMIT = 60
|
||||
|
||||
def create_descendant_thread(starting_depth, statuses)
|
||||
depth = starting_depth + statuses.size
|
||||
|
@ -26,8 +27,61 @@ module StatusControllerConcern
|
|||
end
|
||||
end
|
||||
|
||||
def limit_param(default_limit)
|
||||
return default_limit unless params[:limit]
|
||||
|
||||
[params[:limit].to_i.abs, default_limit * 2].min
|
||||
end
|
||||
|
||||
def set_references
|
||||
limit = limit_param(REFERENCES_LIMIT)
|
||||
max_id = params[:max_id]&.to_i
|
||||
min_id = params[:min_id]&.to_i
|
||||
|
||||
@references = references = cache_collection(
|
||||
@status.thread_references(
|
||||
DESCENDANTS_LIMIT,
|
||||
current_account,
|
||||
params[:max_descendant_thread_id]&.to_i,
|
||||
params[:since_descendant_thread_id]&.to_i,
|
||||
DESCENDANTS_DEPTH_LIMIT
|
||||
),
|
||||
Status
|
||||
)
|
||||
.sort_by {|status| status.id}.reverse
|
||||
|
||||
return if references.empty?
|
||||
|
||||
@references = begin
|
||||
if min_id
|
||||
references.drop_while {|status| status.id >= min_id }.take(limit)
|
||||
elsif max_id
|
||||
references.take_while {|status| status.id > max_id }.reverse.take(limit).reverse
|
||||
else
|
||||
references.take(limit)
|
||||
end
|
||||
end
|
||||
|
||||
return if @references.empty?
|
||||
|
||||
@max_id = @references.first&.id if @references.first.id != references.first.id
|
||||
@min_id = @references.last&.id if @references.last.id != references.last.id
|
||||
end
|
||||
|
||||
def set_ancestors
|
||||
@ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
|
||||
@references = @status.thread_references(
|
||||
DESCENDANTS_LIMIT,
|
||||
current_account,
|
||||
params[:max_descendant_thread_id]&.to_i,
|
||||
params[:since_descendant_thread_id]&.to_i,
|
||||
DESCENDANTS_DEPTH_LIMIT
|
||||
)
|
||||
|
||||
@ancestors = cache_collection(
|
||||
@status.ancestors(ANCESTORS_LIMIT, current_account) + @references,
|
||||
Status
|
||||
).sort_by{|status| status.id}.take(ANCESTORS_LIMIT)
|
||||
|
||||
@next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
|
||||
end
|
||||
|
||||
|
|
|
@ -79,7 +79,12 @@ class Settings::PreferencesController < Settings::BaseController
|
|||
:setting_disable_joke_appearance,
|
||||
:setting_new_features_policy,
|
||||
:setting_theme_instance_ticker,
|
||||
notification_emails: %i(follow follow_request reblog favourite emoji_reaction mention digest report pending_account trending_tag),
|
||||
:setting_enable_status_reference,
|
||||
:setting_match_visibility_of_references,
|
||||
:setting_post_reference_modal,
|
||||
:setting_add_reference_modal,
|
||||
:setting_unselect_reference_modal,
|
||||
notification_emails: %i(follow follow_request reblog favourite emoji_reaction status_reference mention digest report pending_account trending_tag),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
)
|
||||
end
|
||||
|
|
|
@ -12,13 +12,13 @@ class StatusesController < ApplicationController
|
|||
before_action :set_status
|
||||
before_action :set_instance_presenter
|
||||
before_action :set_link_headers
|
||||
before_action :redirect_to_original, only: :show
|
||||
before_action :set_referrer_policy_header, only: :show
|
||||
before_action :redirect_to_original, only: [:show, :references]
|
||||
before_action :set_referrer_policy_header, only: [:show, :references]
|
||||
before_action :set_cache_headers
|
||||
before_action :set_body_classes
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
|
||||
skip_before_action :require_functional!, only: [:show, :references, :embed], unless: :whitelist_mode?
|
||||
|
||||
content_security_policy only: :embed do |p|
|
||||
p.frame_ancestors(false)
|
||||
|
@ -39,6 +39,20 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def references
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
expires_in 10.seconds, public: true if current_account.nil?
|
||||
set_references
|
||||
return not_found unless @references.present?
|
||||
end
|
||||
|
||||
format.json do
|
||||
redirect_to account_status_references_url(@account, @status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def activity
|
||||
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
|
||||
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
||||
|
|
|
@ -27,6 +27,7 @@ module ContextHelper
|
|||
quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' },
|
||||
expiry: { 'fedibird' => 'http://fedibird.com/ns#', 'expiry' => 'fedibird:expiry' },
|
||||
other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' },
|
||||
references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => "fedibird:references", '@type' => '@id' } },
|
||||
is_cat: { 'misskey' => 'https://misskey-hub.net/ns#', 'isCat' => 'misskey:isCat' },
|
||||
vcard: { 'vcard' => 'http://www.w3.org/2006/vcard/ns#' },
|
||||
}.freeze
|
||||
|
|
|
@ -52,7 +52,7 @@ module JsonLdHelper
|
|||
end
|
||||
|
||||
def same_origin?(url_a, url_b)
|
||||
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host).zero?
|
||||
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host)&.zero?
|
||||
end
|
||||
|
||||
def invalid_origin?(url)
|
||||
|
|
|
@ -54,7 +54,18 @@ module StatusesHelper
|
|||
components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')]
|
||||
|
||||
if status.spoiler_text.blank?
|
||||
components << status.text
|
||||
components << Formatter.instance.plaintext(status)
|
||||
components << poll_summary(status)
|
||||
end
|
||||
|
||||
components.reject(&:blank?).join("\n\n")
|
||||
end
|
||||
|
||||
def reference_description(status)
|
||||
components = [status_text_summary(status)]
|
||||
|
||||
if status.spoiler_text.blank?
|
||||
components << [Formatter.instance.plaintext(status).chomp, media_summary(status)].reject(&:blank?).join(' · ')
|
||||
components << poll_summary(status)
|
||||
end
|
||||
|
||||
|
@ -113,6 +124,10 @@ module StatusesHelper
|
|||
end
|
||||
end
|
||||
|
||||
def noindex?(statuses)
|
||||
statuses.map(&:account).uniq.any?(&:noindex?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def simplified_text(text)
|
||||
|
|
BIN
app/javascript/images/mailer/icon_link.png
Normal file
BIN
app/javascript/images/mailer/icon_link.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
|
@ -13,6 +13,8 @@ import { showAlert } from './alerts';
|
|||
import { openModal } from './modal';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { addYears, addMonths, addDays, addHours, addMinutes, addSeconds, millisecondsToSeconds, set, parseISO, formatISO } from 'date-fns';
|
||||
import { Set as ImmutableSet } from 'immutable';
|
||||
import { postReferenceModal } from '../initial_state';
|
||||
|
||||
let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
|
||||
|
||||
|
@ -80,9 +82,15 @@ export const COMPOSE_SCHEDULED_CHANGE = 'COMPOSE_SCHEDULED_CHANGE';
|
|||
export const COMPOSE_EXPIRES_CHANGE = 'COMPOSE_EXPIRES_CHANGE';
|
||||
export const COMPOSE_EXPIRES_ACTION_CHANGE = 'COMPOSE_EXPIRES_ACTION_CHANGE';
|
||||
|
||||
export const COMPOSE_REFERENCE_ADD = 'COMPOSE_REFERENCE_ADD';
|
||||
export const COMPOSE_REFERENCE_REMOVE = 'COMPOSE_REFERENCE_REMOVE';
|
||||
export const COMPOSE_REFERENCE_RESET = 'COMPOSE_REFERENCE_RESET';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
|
||||
postReferenceMessage: { id: 'confirmations.post_reference.message', defaultMessage: 'It contains references, do you want to post it?' },
|
||||
postReferenceConfirm: { id: 'confirmations.post_reference.confirm', defaultMessage: 'Post' },
|
||||
});
|
||||
|
||||
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
|
||||
|
@ -93,6 +101,16 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getContextReference = (getState, status) => {
|
||||
if (!status) {
|
||||
return ImmutableSet();
|
||||
}
|
||||
|
||||
const references = status.get('status_reference_ids').toSet();
|
||||
const replyStatus = status.get('in_reply_to_id') ? getState().getIn(['statuses', status.get('in_reply_to_id')]) : null;
|
||||
return references.concat(getContextReference(getState, replyStatus));
|
||||
};
|
||||
|
||||
export function changeCompose(text) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
|
@ -105,6 +123,7 @@ export function replyCompose(status, routerHistory) {
|
|||
dispatch({
|
||||
type: COMPOSE_REPLY,
|
||||
status: status,
|
||||
context_references: getContextReference(getState, status),
|
||||
});
|
||||
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
|
@ -203,6 +222,28 @@ export const getDateTimeFromText = (value, origin = new Date()) => {
|
|||
};
|
||||
};
|
||||
|
||||
export function submitComposeWithCheck(routerHistory, intl) {
|
||||
return function (dispatch, getState) {
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const statusReferenceIds = getState().getIn(['compose', 'references']);
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (postReferenceModal && !statusReferenceIds.isEmpty()) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.postReferenceMessage),
|
||||
confirm: intl.formatMessage(messages.postReferenceConfirm),
|
||||
onConfirm: () => dispatch(submitCompose(routerHistory)),
|
||||
}));
|
||||
} else {
|
||||
dispatch(submitCompose(routerHistory));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function submitCompose(routerHistory) {
|
||||
return function (dispatch, getState) {
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
|
@ -212,6 +253,7 @@ export function submitCompose(routerHistory) {
|
|||
const { in: scheduled_in = null, at: scheduled_at = null } = getDateTimeFromText(getState().getIn(['compose', 'scheduled']), new Date());
|
||||
const { in: expires_in = null, at: expires_at = null } = getDateTimeFromText(getState().getIn(['compose', 'expires']), scheduled_at ?? new Date());
|
||||
const expires_action = getState().getIn(['compose', 'expires_action']);
|
||||
const statusReferenceIds = getState().getIn(['compose', 'references']);
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
|
@ -234,6 +276,7 @@ export function submitCompose(routerHistory) {
|
|||
expires_at: !expires_in && expires_at ? formatISO(set(expires_at, { seconds: 59 })) : null,
|
||||
expires_in: expires_in,
|
||||
expires_action: expires_action,
|
||||
status_reference_ids: statusReferenceIds,
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
@ -810,3 +853,34 @@ export function changeExpiresAction(value) {
|
|||
value: value,
|
||||
};
|
||||
};
|
||||
|
||||
export function addReference(id, change) {
|
||||
return (dispatch, getState) => {
|
||||
if (change) {
|
||||
const status = getState().getIn(['statuses', id]);
|
||||
const visibility = getState().getIn(['compose', 'privacy']);
|
||||
|
||||
if (status && status.get('visibility') === 'private' && ['public', 'unlisted'].includes(visibility)) {
|
||||
dispatch(changeComposeVisibility('private'));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_REFERENCE_ADD,
|
||||
id: id,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function removeReference(id) {
|
||||
return {
|
||||
type: COMPOSE_REFERENCE_REMOVE,
|
||||
id: id,
|
||||
};
|
||||
};
|
||||
|
||||
export function resetReference() {
|
||||
return {
|
||||
type: COMPOSE_REFERENCE_RESET,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -58,6 +58,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
// Otherwise keep the ones already in the reducer
|
||||
if (normalOldStatus) {
|
||||
normalStatus.search_index = normalOldStatus.get('search_index');
|
||||
normalStatus.shortHtml = normalOldStatus.get('shortHtml');
|
||||
normalStatus.contentHtml = normalOldStatus.get('contentHtml');
|
||||
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
|
||||
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
|
||||
|
@ -73,11 +74,16 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoiler_text = '';
|
||||
}
|
||||
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const emojiMap = makeEmojiMap(normalStatus);
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const emojiMap = makeEmojiMap(normalStatus);
|
||||
|
||||
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||
const docContentElem = domParser.parseFromString(searchContent, 'text/html').documentElement;
|
||||
docContentElem.querySelector('.quote-inline')?.remove();
|
||||
docContentElem.querySelector('.reference-link-inline')?.remove();
|
||||
|
||||
normalStatus.search_index = docContentElem.textContent;
|
||||
normalStatus.shortHtml = '<p>'+emojify(normalStatus.search_index.substr(0, 150), emojiMap) + (normalStatus.search_index.substr(150) ? '...' : '')+'</p>';
|
||||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import api, { getLinks } from '../api';
|
||||
import { importFetchedAccounts, importFetchedStatus } from './importer';
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
import { fetchRelationships, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
||||
|
@ -43,6 +43,14 @@ export const EMOJI_REACTIONS_EXPAND_REQUEST = 'EMOJI_REACTIONS_EXPAND_REQUEST';
|
|||
export const EMOJI_REACTIONS_EXPAND_SUCCESS = 'EMOJI_REACTIONS_EXPAND_SUCCESS';
|
||||
export const EMOJI_REACTIONS_EXPAND_FAIL = 'EMOJI_REACTIONS_EXPAND_FAIL';
|
||||
|
||||
export const REFERRED_BY_STATUSES_FETCH_REQUEST = 'REFERRED_BY_STATUSES_FETCH_REQUEST';
|
||||
export const REFERRED_BY_STATUSES_FETCH_SUCCESS = 'REFERRED_BY_STATUSES_FETCH_SUCCESS';
|
||||
export const REFERRED_BY_STATUSES_FETCH_FAIL = 'REFERRED_BY_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const REFERRED_BY_STATUSES_EXPAND_REQUEST = 'REFERRED_BY_STATUSES_EXPAND_REQUEST';
|
||||
export const REFERRED_BY_STATUSES_EXPAND_SUCCESS = 'REFERRED_BY_STATUSES_EXPAND_SUCCESS';
|
||||
export const REFERRED_BY_STATUSES_EXPAND_FAIL = 'REFERRED_BY_STATUSES_EXPAND_FAIL';
|
||||
|
||||
export const MENTIONS_FETCH_REQUEST = 'MENTIONS_FETCH_REQUEST';
|
||||
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
|
||||
export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL';
|
||||
|
@ -337,6 +345,7 @@ export function fetchReblogsSuccess(id, accounts, next) {
|
|||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -381,6 +390,7 @@ export function expandReblogsSuccess(id, accounts, next) {
|
|||
export function expandReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -419,6 +429,7 @@ export function fetchFavouritesSuccess(id, accounts, next) {
|
|||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -463,6 +474,7 @@ export function expandFavouritesSuccess(id, accounts, next) {
|
|||
export function expandFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -501,6 +513,7 @@ export function fetchEmojiReactionsSuccess(id, emojiReactions, next) {
|
|||
export function fetchEmojiReactionsFail(id, error) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -545,6 +558,95 @@ export function expandEmojiReactionsSuccess(id, emojiReactions, next) {
|
|||
export function expandEmojiReactionsFail(id, error) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReferredByStatuses(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReferredByStatusesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/referred_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
const statuses = response.data;
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch(fetchRelationshipsFromStatuses(statuses));
|
||||
dispatch(fetchAccountsFromStatuses(statuses));
|
||||
dispatch(fetchReferredByStatusesSuccess(id, statuses, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReferredByStatusesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReferredByStatusesRequest(id) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReferredByStatusesSuccess(id, statuses, next) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_FETCH_SUCCESS,
|
||||
id,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReferredByStatusesFail(id, error) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandReferredByStatuses(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['status_status_lists', 'referred_by', id, 'next'], null);
|
||||
|
||||
if (url === null || getState().getIn(['status_status_lists', 'referred_by', id, 'isLoading'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandReferredByStatusesRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
const statuses = response.data;
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch(fetchRelationshipsFromStatuses(statuses));
|
||||
dispatch(fetchAccountsFromStatuses(statuses));
|
||||
dispatch(expandReferredByStatusesSuccess(id, statuses, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandReferredByStatusesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandReferredByStatusesRequest(id) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandReferredByStatusesSuccess(id, statuses, next) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_EXPAND_SUCCESS,
|
||||
id,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandReferredByStatusesFail(id, error) {
|
||||
return {
|
||||
type: REFERRED_BY_STATUSES_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -583,6 +685,7 @@ export function fetchMentionsSuccess(id, accounts, next) {
|
|||
export function fetchMentionsFail(id, error) {
|
||||
return {
|
||||
type: MENTIONS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -627,6 +730,7 @@ export function expandMentionsSuccess(id, accounts, next) {
|
|||
export function expandMentionsFail(id, error) {
|
||||
return {
|
||||
type: MENTIONS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { getFiltersRegex } from '../selectors';
|
||||
import { usePendingItems as preferPendingItems, enableReaction } from 'mastodon/initial_state';
|
||||
import { usePendingItems as preferPendingItems, enableReaction, enableStatusReference } from 'mastodon/initial_state';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
@ -66,7 +66,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
let filtered = false;
|
||||
|
||||
if (['mention', 'status'].includes(notification.type)) {
|
||||
if (['mention', 'status', 'status_reference'].includes(notification.type)) {
|
||||
const dropRegex = filters[0];
|
||||
const regex = filters[1];
|
||||
const searchIndex = searchTextFromRawStatus(notification.status);
|
||||
|
@ -120,10 +120,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', enableReaction ? 'emoji_reaction' : null, enableStatusReference ? 'status_reference' : null].filter(x => !!x))
|
||||
|
||||
const excludeTypesFromFilter = filter => {
|
||||
const allTypes = enableReaction ?
|
||||
ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'emoji_reaction']) :
|
||||
ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
|
||||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
};
|
||||
|
||||
|
|
|
@ -3,12 +3,16 @@ import api from '../api';
|
|||
import { deleteFromTimelines } from './timelines';
|
||||
import { fetchRelationshipsFromStatus, fetchAccountsFromStatus, fetchRelationshipsFromStatuses, fetchAccountsFromStatuses } from './accounts';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { ensureComposeIsVisible } from './compose';
|
||||
import { ensureComposeIsVisible, getContextReference } from './compose';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||
|
||||
export const STATUSES_FETCH_REQUEST = 'STATUSES_FETCH_REQUEST';
|
||||
export const STATUSES_FETCH_SUCCESS = 'STATUSES_FETCH_SUCCESS';
|
||||
export const STATUSES_FETCH_FAIL = 'STATUSES_FETCH_FAIL';
|
||||
|
||||
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
@ -83,12 +87,58 @@ export function fetchStatusFail(id, error, skipLoading) {
|
|||
};
|
||||
};
|
||||
|
||||
export function redraft(status, replyStatus, raw_text) {
|
||||
export function fetchStatusesRequest(ids) {
|
||||
return {
|
||||
type: STATUSES_FETCH_REQUEST,
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatuses(ids) {
|
||||
return (dispatch, getState) => {
|
||||
const loadedStatuses = getState().get('statuses', new Map());
|
||||
const newStatusIds = Array.from(new Set(ids)).filter(id => loadedStatuses.get(id, null) === null);
|
||||
|
||||
if (newStatusIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatusesRequest(newStatusIds));
|
||||
|
||||
api(getState).get(`/api/v1/statuses?${newStatusIds.map(id => `ids[]=${id}`).join('&')}`).then(response => {
|
||||
const statuses = response.data;
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch(fetchRelationshipsFromStatuses(statuses));
|
||||
dispatch(fetchAccountsFromStatuses(statuses));
|
||||
dispatch(fetchStatusesSuccess());
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusesSuccess() {
|
||||
return {
|
||||
type: STATUSES_FETCH_SUCCESS,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusesFail(id, error) {
|
||||
return {
|
||||
type: STATUSES_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function redraft(getState, status, replyStatus, raw_text) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
replyStatus,
|
||||
raw_text,
|
||||
context_references: getContextReference(getState, replyStatus),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -109,7 +159,8 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
|
|||
dispatch(importFetchedAccount(response.data.account));
|
||||
|
||||
if (withRedraft) {
|
||||
dispatch(redraft(status, replyStatus, response.data.text));
|
||||
dispatch(fetchStatuses(status.get('status_reference_ids', [])));
|
||||
dispatch(redraft(getState, status, replyStatus, response.data.text));
|
||||
ensureComposeIsVisible(getState, routerHistory);
|
||||
}
|
||||
}).catch(error => {
|
||||
|
@ -144,12 +195,12 @@ export function fetchContext(id) {
|
|||
return (dispatch, getState) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
const statuses = response.data.ancestors.concat(response.data.descendants);
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`, { params: { with_reference: true } }).then(response => {
|
||||
const statuses = response.data.ancestors.concat(response.data.descendants).concat(response.data.references);
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch(fetchRelationshipsFromStatuses(statuses));
|
||||
dispatch(fetchAccountsFromStatuses(statuses));
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants, response.data.references));
|
||||
|
||||
}).catch(error => {
|
||||
if (error.response && error.response.status === 404) {
|
||||
|
@ -168,12 +219,13 @@ export function fetchContextRequest(id) {
|
|||
};
|
||||
};
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
export function fetchContextSuccess(id, ancestors, descendants, references) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
references,
|
||||
statuses: ancestors.concat(descendants),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -2,21 +2,28 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
||||
const formatNumber = num => num > 40 ? '40+' : num;
|
||||
const IconWithBadge = ({ id, count, countMax, issueBadge, className }) => {
|
||||
const formatNumber = num => num > countMax ? `${countMax}+` : num;
|
||||
|
||||
const IconWithBadge = ({ id, count, issueBadge, className }) => (
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id={id} fixedWidth className={className} />
|
||||
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
|
||||
{issueBadge && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
);
|
||||
return (
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id={id} fixedWidth className={className} />
|
||||
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
|
||||
{issueBadge && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
)
|
||||
};
|
||||
|
||||
IconWithBadge.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
countMax: PropTypes.number,
|
||||
issueBadge: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
IconWithBadge.defaultProps = {
|
||||
countMax: 40,
|
||||
};
|
||||
|
||||
export default IconWithBadge;
|
||||
|
|
|
@ -10,7 +10,6 @@ import DisplayName from './display_name';
|
|||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import AccountActionBar from './account_action_bar';
|
||||
import AttachmentList from './attachment_list';
|
||||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
@ -20,7 +19,8 @@ import classNames from 'classnames';
|
|||
import Icon from 'mastodon/components/icon';
|
||||
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { displayMedia, enableReaction } from 'mastodon/initial_state';
|
||||
import { displayMedia, enableReaction, show_reply_tree_button, enableStatusReference } from 'mastodon/initial_state';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
|
@ -85,6 +85,9 @@ const messages = defineMessages({
|
|||
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual-followers-only' },
|
||||
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
mark_ancestor: { id: 'thread_mark.ancestor', defaultMessage: 'Has reference' },
|
||||
mark_descendant: { id: 'thread_mark.descendant', defaultMessage: 'Has reply' },
|
||||
mark_both: { id: 'thread_mark.both', defaultMessage: 'Has reference and reply' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
|
@ -108,6 +111,8 @@ class Status extends ImmutablePureComponent {
|
|||
status: ImmutablePropTypes.map,
|
||||
account: ImmutablePropTypes.map,
|
||||
otherAccounts: ImmutablePropTypes.list,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
quote_muted: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
onReply: PropTypes.func,
|
||||
|
@ -126,6 +131,7 @@ class Status extends ImmutablePureComponent {
|
|||
onToggleHidden: PropTypes.func,
|
||||
onToggleCollapsed: PropTypes.func,
|
||||
onQuoteToggleHidden: PropTypes.func,
|
||||
onReference: PropTypes.func,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
|
@ -158,6 +164,8 @@ class Status extends ImmutablePureComponent {
|
|||
'hidden',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'referenced',
|
||||
'contextReferenced',
|
||||
'quote_muted',
|
||||
];
|
||||
|
||||
|
@ -373,7 +381,7 @@ class Status extends ImmutablePureComponent {
|
|||
let media = null;
|
||||
let statusAvatar, prepend, rebloggedByText;
|
||||
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted } = this.props;
|
||||
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, contextType, quote_muted, referenced, contextReferenced } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
|
@ -685,17 +693,51 @@ class Status extends ImmutablePureComponent {
|
|||
const expires_date = expires_at && new Date(expires_at)
|
||||
const expired = expires_date && expires_date.getTime() < intl.now()
|
||||
|
||||
const ancestorCount = showThread && show_reply_tree_button && status.get('in_reply_to_id', 0) > 0 ? 1 : 0;
|
||||
const descendantCount = showThread && show_reply_tree_button ? status.get('replies_count', 0) : 0;
|
||||
const referenceCount = enableStatusReference ? status.get('status_references_count', 0) - (status.get('status_reference_ids', ImmutableList()).includes(status.get('quote_id')) ? 1 : 0) : 0;
|
||||
const threadCount = ancestorCount + descendantCount + referenceCount;
|
||||
|
||||
let threadMarkTitle = '';
|
||||
|
||||
if (ancestorCount + referenceCount > 0) {
|
||||
if (descendantCount > 0) {
|
||||
threadMarkTitle = intl.formatMessage(messages.mark_both);
|
||||
} else {
|
||||
threadMarkTitle = intl.formatMessage(messages.mark_ancestor);
|
||||
}
|
||||
} else if (descendantCount > 0) {
|
||||
threadMarkTitle = intl.formatMessage(messages.mark_descendant);
|
||||
}
|
||||
|
||||
const threadMark = threadCount > 0 ? <span className={classNames('status__thread_mark', {
|
||||
'status__thread_mark-ancenstor': (ancestorCount + referenceCount) > 0,
|
||||
'status__thread_mark-descendant': descendantCount > 0,
|
||||
})} title={threadMarkTitle}>+</span> : null;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted, 'status__wrapper-with-expiration': expires_date, 'status__wrapper-expired': expired })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, {
|
||||
'status__wrapper-reply': !!status.get('in_reply_to_id'),
|
||||
unread,
|
||||
focusable: !this.props.muted,
|
||||
'status__wrapper-with-expiration': expires_date,
|
||||
'status__wrapper-expired': expired,
|
||||
'status__wrapper-referenced': referenced,
|
||||
'status__wrapper-context-referenced': contextReferenced,
|
||||
'status__wrapper-reference': referenceCount > 0,
|
||||
})} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
{prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired })} data-id={status.get('id')}>
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, 'status-with-expiration': expires_date, 'status-expired': expired, referenced, 'context-referenced': contextReferenced })} data-id={status.get('id')}>
|
||||
<AccountActionBar account={status.get('account')} {...other} />
|
||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||
<div className='status__info'>
|
||||
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className="fa fa-clock-o" aria-hidden="true"></i></time></span>}
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
|
||||
{threadMark}
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</a>
|
||||
<span className='status__visibility-icon'>{visibilityLink}</span>
|
||||
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
|
|
|
@ -6,8 +6,9 @@ import IconButton from './icon_button';
|
|||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me, isStaff, show_bookmark_button, show_quote_button, enableReaction } from '../initial_state';
|
||||
import { me, isStaff, show_bookmark_button, show_quote_button, enableReaction, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from '../initial_state';
|
||||
import classNames from 'classnames';
|
||||
import { openModal } from '../actions/modal';
|
||||
|
||||
import ReactionPickerDropdownContainer from '../containers/reaction_picker_dropdown_container';
|
||||
|
||||
|
@ -23,6 +24,7 @@ const messages = defineMessages({
|
|||
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reference: { id: 'status.reference', defaultMessage: 'Reference' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
|
@ -37,6 +39,7 @@ const messages = defineMessages({
|
|||
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
|
||||
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited users' },
|
||||
show_emoji_reactions: { id: 'status.show_emoji_reactions', defaultMessage: 'Show emoji reactioned users' },
|
||||
show_referred_by_statuses: { id: 'status.show_referred_by_statuses', defaultMessage: 'Show referred by statuses' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
|
@ -51,10 +54,17 @@ const messages = defineMessages({
|
|||
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
|
||||
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
|
||||
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
|
||||
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
|
||||
selected: state.getIn(['compose', 'references']).has(status.get('id')),
|
||||
composePrivacy: state.getIn(['compose', 'privacy']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
@ -68,7 +78,12 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
expired: PropTypes.bool,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
referenceCountLimit: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
composePrivacy: PropTypes.string,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
|
@ -88,6 +103,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onAddReference: PropTypes.func,
|
||||
onRemoveReference: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
scrollKey: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
@ -105,6 +122,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
'status',
|
||||
'relationship',
|
||||
'withDismiss',
|
||||
'referenced',
|
||||
'contextReferenced',
|
||||
'referenceCountLimit'
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
|
@ -140,6 +160,31 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleReferenceClick = (e) => {
|
||||
const { dispatch, intl, status, selected, composePrivacy, onAddReference, onRemoveReference } = this.props;
|
||||
const id = status.get('id');
|
||||
|
||||
if (selected) {
|
||||
onRemoveReference(id);
|
||||
} else {
|
||||
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
|
||||
if (!addReferenceModal || e && e.shiftKey) {
|
||||
onAddReference(id, true);
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
|
||||
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
|
||||
onConfirm: () => onAddReference(id, matchVisibilityOfReferences),
|
||||
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
|
||||
onSecondary: () => onAddReference(id, !matchVisibilityOfReferences),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
onAddReference(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_openInteractionDialog = type => {
|
||||
window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
|
||||
}
|
||||
|
@ -266,6 +311,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/emoji_reactions`);
|
||||
}
|
||||
|
||||
handleReferredByStatuses = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/referred_by`);
|
||||
}
|
||||
|
||||
handleEmojiPick = data => {
|
||||
const { addEmojiReaction, status } = this.props;
|
||||
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
|
||||
|
@ -277,7 +326,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, scrollKey, expired } = this.props;
|
||||
const { status, relationship, intl, withDismiss, scrollKey, expired, referenced, contextReferenced, referenceCountLimit } = this.props;
|
||||
|
||||
const anonymousAccess = !me;
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
@ -290,6 +339,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
const bookmarked = status.get('bookmarked');
|
||||
const emoji_reactioned = status.get('emoji_reactioned');
|
||||
const reblogsCount = status.get('reblogs_count');
|
||||
const referredByCount = status.get('status_referred_by_count');
|
||||
const favouritesCount = status.get('favourites_count');
|
||||
const [ _, domain ] = account.get('acct').split('@');
|
||||
|
||||
|
@ -318,6 +368,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.show_emoji_reactions), action: this.handleEmojiReactions });
|
||||
}
|
||||
|
||||
if (enableStatusReference && referredByCount > 0) {
|
||||
menu.push({ text: intl.formatMessage(messages.show_referred_by_statuses), action: this.handleReferredByStatuses });
|
||||
}
|
||||
|
||||
if (domain) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
|
||||
|
@ -413,9 +467,12 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' disabled={expired} title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' disabled={expired} title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={reblogged} pressed={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate disabled={!favourited && expired} active={favourited} pressed={favourited} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{show_quote_button && <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}
|
||||
|
|
|
@ -6,7 +6,7 @@ import Permalink from './permalink';
|
|||
import classnames from 'classnames';
|
||||
import PollContainer from 'mastodon/containers/poll_container';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { autoPlayGif, show_reply_tree_button } from 'mastodon/initial_state';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
linkToAcct: { id: 'status.link_to_acct', defaultMessage: 'Link to @{acct}' },
|
||||
|
@ -39,13 +39,22 @@ export default class StatusContent extends React.PureComponent {
|
|||
};
|
||||
|
||||
_updateStatusLinks () {
|
||||
const { intl, status, collapsable, onClick, onCollapsedToggle } = this.props;
|
||||
const node = this.node;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = node.querySelectorAll('a');
|
||||
const reference_link = node.querySelector('.reference-link-inline > a');
|
||||
if (reference_link && reference_link?.dataset?.statusId && !reference_link.hasReferenceClick ) {
|
||||
reference_link.addEventListener('click', this.onReferenceLinkClick.bind(this, reference_link.dataset.statusId), false);
|
||||
reference_link.setAttribute('target', '_blank');
|
||||
reference_link.setAttribute('rel', 'noopener noreferrer');
|
||||
reference_link.hasReferenceClick = true;
|
||||
}
|
||||
|
||||
const links = node.querySelectorAll(':not(.reference-link-inline) > a');
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
|
@ -54,7 +63,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
link.classList.add('status-link');
|
||||
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
let mention = status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
if (mention.get('group', false)) {
|
||||
|
@ -66,10 +75,10 @@ export default class StatusContent extends React.PureComponent {
|
|||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else if (link.classList.contains('account-url-link')) {
|
||||
link.setAttribute('title', this.props.intl.formatMessage(messages.linkToAcct, { acct: link.dataset.accountAcct }));
|
||||
link.setAttribute('title', intl.formatMessage(messages.linkToAcct, { acct: link.dataset.accountAcct }));
|
||||
link.addEventListener('click', this.onAccountUrlClick.bind(this, link.dataset.accountId, link.dataset.accountActorType), false);
|
||||
} else if (link.classList.contains('status-url-link')) {
|
||||
link.setAttribute('title', this.props.intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct }));
|
||||
} else if (link.classList.contains('status-url-link') && ![status.get('uri'), status.get('url')].includes(link.href)) {
|
||||
link.setAttribute('title', intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct }));
|
||||
link.addEventListener('click', this.onStatusUrlClick.bind(this, link.dataset.statusId), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
|
@ -80,16 +89,16 @@ export default class StatusContent extends React.PureComponent {
|
|||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (this.props.status.get('collapsed', null) === null) {
|
||||
if (status.get('collapsed', null) === null) {
|
||||
let collapsed =
|
||||
this.props.collapsable
|
||||
&& this.props.onClick
|
||||
collapsable
|
||||
&& onClick
|
||||
&& node.clientHeight > MAX_HEIGHT
|
||||
&& this.props.status.get('spoiler_text').length === 0;
|
||||
&& status.get('spoiler_text').length === 0;
|
||||
|
||||
if(this.props.onCollapsedToggle) this.props.onCollapsedToggle(collapsed);
|
||||
if(onCollapsedToggle) onCollapsedToggle(collapsed);
|
||||
|
||||
this.props.status.set('collapsed', collapsed);
|
||||
status.set('collapsed', collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -173,6 +182,13 @@ export default class StatusContent extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onReferenceLinkClick = (statusId, e) => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/statuses/${statusId}/references`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
this.startXY = [e.clientX, e.clientY];
|
||||
}
|
||||
|
@ -221,8 +237,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
const renderViewThread = this.props.showThread && (
|
||||
status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ||
|
||||
show_reply_tree_button && (status.get('in_reply_to_id') || !!status.get('replies_count'))
|
||||
status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])
|
||||
);
|
||||
const renderShowPoll = !!status.get('poll');
|
||||
|
||||
|
|
114
app/javascript/mastodon/components/status_item.js
Normal file
114
app/javascript/mastodon/components/status_item.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
166
app/javascript/mastodon/components/thumbnail_gallery.js
Normal file
166
app/javascript/mastodon/components/thumbnail_gallery.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -7,6 +7,8 @@ import {
|
|||
quoteCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
addReference,
|
||||
removeReference,
|
||||
} from '../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
|
@ -74,12 +76,21 @@ const makeMapStateToProps = () => {
|
|||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
|
||||
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, props),
|
||||
pictureInPicture: getPictureInPicture(state, props),
|
||||
emojiMap: customEmojiMap(state),
|
||||
});
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, props);
|
||||
const id = !!status ? getProper(status).get('id') : null;
|
||||
|
||||
return {
|
||||
status,
|
||||
pictureInPicture: getPictureInPicture(state, props),
|
||||
emojiMap: customEmojiMap(state),
|
||||
id,
|
||||
referenced: state.getIn(['compose', 'references']).has(id),
|
||||
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
|
||||
}
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
@ -308,6 +319,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
dispatch(removeEmojiReaction(status));
|
||||
},
|
||||
|
||||
onAddReference (id, change) {
|
||||
dispatch(addReference(id, change));
|
||||
},
|
||||
|
||||
onRemoveReference (id) {
|
||||
dispatch(removeReference(id));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
||||
|
|
52
app/javascript/mastodon/containers/status_item_container.js
Normal file
52
app/javascript/mastodon/containers/status_item_container.js
Normal 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));
|
|
@ -19,6 +19,7 @@ import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
|||
import PollFormContainer from '../containers/poll_form_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import ReferenceStack from '../../../features/reference_stack';
|
||||
import { isMobile } from '../../../is_mobile';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { length } from 'stringz';
|
||||
|
@ -273,6 +274,8 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<div className='compose-form__publish'>
|
||||
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
|
||||
</div>
|
||||
|
||||
<ReferenceStack />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||
import ComposeForm from '../components/compose_form';
|
||||
import {
|
||||
changeCompose,
|
||||
submitCompose,
|
||||
submitComposeWithCheck,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
|
@ -10,6 +10,7 @@ import {
|
|||
insertEmojiCompose,
|
||||
uploadCompose,
|
||||
} from '../../../actions/compose';
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
text: state.getIn(['compose', 'text']),
|
||||
|
@ -28,14 +29,14 @@ const mapStateToProps = state => ({
|
|||
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onChange (text) {
|
||||
dispatch(changeCompose(text));
|
||||
},
|
||||
|
||||
onSubmit (router) {
|
||||
dispatch(submitCompose(router));
|
||||
dispatch(submitComposeWithCheck(router, intl));
|
||||
},
|
||||
|
||||
onClearSuggestions () {
|
||||
|
@ -64,4 +65,4 @@ const mapDispatchToProps = (dispatch) => ({
|
|||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, initMediaEditModal } from '../../../actions/compose';
|
||||
import { submitCompose } from '../../../actions/compose';
|
||||
import { submitComposeWithCheck } from '../../../actions/compose';
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onUndo: id => {
|
||||
dispatch(undoUploadCompose(id));
|
||||
|
@ -18,9 +19,9 @@ const mapDispatchToProps = dispatch => ({
|
|||
},
|
||||
|
||||
onSubmit (router) {
|
||||
dispatch(submitCompose(router));
|
||||
dispatch(submitComposeWithCheck(router, intl));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload);
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Upload));
|
||||
|
|
|
@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
import ClearColumnButton from './clear_column_button';
|
||||
import GrantPermissionButton from './grant_permission_button';
|
||||
import SettingToggle from './setting_toggle';
|
||||
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
|
||||
|
||||
export default class ColumnSettings extends React.PureComponent {
|
||||
|
||||
|
@ -153,16 +154,30 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-reaction'>
|
||||
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span>
|
||||
{enableReaction &&
|
||||
<div role='group' aria-labelledby='notifications-reaction'>
|
||||
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{enableStatusReference &&
|
||||
<div role='group' aria-labelledby='notifications-status-reference'>
|
||||
<span id='notifications-status-reference' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status_reference' defaultMessage='Status references:' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} />
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status_reference']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status_reference']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status_reference']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status_reference']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
|
||||
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
|
@ -11,6 +12,7 @@ const tooltips = defineMessages({
|
|||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||
reactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Reactions' },
|
||||
reference: { id: 'notifications.filter.status_references', defaultMessage: 'Status references' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
|
@ -96,13 +98,24 @@ class FilterBar extends React.PureComponent {
|
|||
>
|
||||
<Icon id='home' fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('emoji_reaction')}
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='smile-o' fixedWidth />
|
||||
</button>
|
||||
{enableReaction &&
|
||||
<button
|
||||
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('emoji_reaction')}
|
||||
title={intl.formatMessage(tooltips.reactions)}
|
||||
>
|
||||
<Icon id='smile-o' fixedWidth />
|
||||
</button>
|
||||
}
|
||||
{enableStatusReference &&
|
||||
<button
|
||||
className={selectedFilter === 'status_reference' ? 'active' : ''}
|
||||
onClick={this.onClick('status_reference')}
|
||||
title={intl.formatMessage(tooltips.reference)}
|
||||
>
|
||||
<Icon id='link' fixedWidth />
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
className={selectedFilter === 'follow' ? 'active' : ''}
|
||||
onClick={this.onClick('follow')}
|
||||
|
|
|
@ -21,6 +21,7 @@ const messages = defineMessages({
|
|||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
|
||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' },
|
||||
status_reference: { id: 'notification.status_reference', defaultMessage: '{name} referenced your post' },
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
@ -350,6 +351,38 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderStatusReference (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-status-reference focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status_reference, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='link' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.status_reference' defaultMessage='{name} referenced your post' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusContainer
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { notification } = this.props;
|
||||
const account = notification.get('account');
|
||||
|
@ -373,6 +406,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderPoll(notification, account);
|
||||
case 'emoji_reaction':
|
||||
return this.renderReaction(notification, link);
|
||||
case 'status_reference':
|
||||
return this.renderStatusReference(notification, link);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -5,9 +5,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import PropTypes from 'prop-types';
|
||||
import IconButton from 'mastodon/components/icon_button';
|
||||
import classNames from 'classnames';
|
||||
import { me, boostModal, show_quote_button } from 'mastodon/initial_state';
|
||||
import { me, boostModal, show_quote_button, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from 'mastodon/initial_state';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { replyCompose, quoteCompose } from 'mastodon/actions/compose';
|
||||
import { replyCompose, quoteCompose, addReference, removeReference } from 'mastodon/actions/compose';
|
||||
import { reblog, favourite, bookmark, unreblog, unfavourite, unbookmark } from 'mastodon/actions/interactions';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { initBoostModal } from 'mastodon/actions/boosts';
|
||||
|
@ -16,6 +16,7 @@ import { openModal } from 'mastodon/actions/modal';
|
|||
const messages = defineMessages({
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reference: { id: 'status.reference', defaultMessage: 'Reference' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
|
@ -29,15 +30,29 @@ const messages = defineMessages({
|
|||
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
|
||||
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
|
||||
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
|
||||
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
|
||||
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
status: getStatus(state, { id: statusId }),
|
||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
});
|
||||
const mapStateToProps = (state, { statusId }) => {
|
||||
const status = getStatus(state, { id: statusId });
|
||||
const id = status ? getProper(status).get('id') : null;
|
||||
|
||||
return {
|
||||
status,
|
||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
|
||||
referenced: state.getIn(['compose', 'references']).has(id),
|
||||
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
|
||||
composePrivacy: state.getIn(['compose', 'privacy']),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
@ -53,6 +68,10 @@ class Footer extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
referenceCountLimit: PropTypes.bool,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
composePrivacy: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
|
@ -85,6 +104,39 @@ class Footer extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleReferenceClick = (e) => {
|
||||
const { dispatch, intl, status, referenced, composePrivacy } = this.props;
|
||||
const id = status.get('id');
|
||||
|
||||
if (referenced) {
|
||||
this.handleRemoveReference(id);
|
||||
} else {
|
||||
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
|
||||
if (!addReferenceModal || e && e.shiftKey) {
|
||||
this.handleAddReference(id, true);
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
|
||||
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
|
||||
onConfirm: () => this.handleAddReference(id, matchVisibilityOfReferences),
|
||||
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
|
||||
onSecondary: () => this.handleAddReference(id, !matchVisibilityOfReferences),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.handleAddReference(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleAddReference = (id, change) => {
|
||||
this.props.dispatch(addReference(id, change));
|
||||
}
|
||||
|
||||
handleRemoveReference = (id) => {
|
||||
this.props.dispatch(removeReference(id));
|
||||
}
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
const { dispatch, status } = this.props;
|
||||
|
||||
|
@ -164,7 +216,7 @@ class Footer extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withOpenButton } = this.props;
|
||||
const { status, intl, withOpenButton, referenced, contextReferenced, referenceCountLimit } = this.props;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
@ -195,9 +247,12 @@ class Footer extends ImmutablePureComponent {
|
|||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
}
|
||||
|
||||
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
|
||||
|
||||
return (
|
||||
<div className='picture-in-picture__footer'>
|
||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||
{enableStatusReference && me && <IconButton className={classNames('status__action-bar-button', 'link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} />}
|
||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||
{show_quote_button && <IconButton className='status__action-bar-button' disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}
|
||||
|
|
|
@ -42,6 +42,7 @@ export default class Header extends ImmutablePureComponent {
|
|||
<NavLink exact to={`/statuses/${status.get('id')}/reblogs`}><FormattedMessage id='status.reblog' defaultMessage='Boost' /></NavLink>
|
||||
<NavLink exact to={`/statuses/${status.get('id')}/favourites`}><FormattedMessage id='status.favourite' defaultMessage='Favourite' /></NavLink>
|
||||
<NavLink exact to={`/statuses/${status.get('id')}/emoji_reactions`}><FormattedMessage id='status.emoji' defaultMessage='Emoji' /></NavLink>
|
||||
<NavLink exact to={`/statuses/${status.get('id')}/referred_by`}><FormattedMessage id='status.referred_by' defaultMessage='Referred' /></NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
105
app/javascript/mastodon/features/reference_stack/index.js
Normal file
105
app/javascript/mastodon/features/reference_stack/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -5,9 +5,10 @@ import IconButton from '../../../components/icon_button';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { me, isStaff, show_quote_button, enableReaction } from '../../../initial_state';
|
||||
import { me, isStaff, show_quote_button, enableReaction, enableStatusReference, maxReferences, matchVisibilityOfReferences, addReferenceModal } from '../../../initial_state';
|
||||
import classNames from 'classnames';
|
||||
import ReactionPickerDropdownContainer from 'mastodon/containers/reaction_picker_dropdown_container';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
@ -16,6 +17,7 @@ const messages = defineMessages({
|
|||
showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
reference: { id: 'status.reference', defaultMessage: 'Reference' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
|
@ -28,6 +30,7 @@ const messages = defineMessages({
|
|||
show_reblogs: { id: 'status.show_reblogs', defaultMessage: 'Show boosted users' },
|
||||
show_favourites: { id: 'status.show_favourites', defaultMessage: 'Show favourited users' },
|
||||
show_emoji_reactions: { id: 'status.show_emoji_reactions', defaultMessage: 'Show emoji reactioned users' },
|
||||
show_referred_by_statuses: { id: 'status.show_referred_by_statuses', defaultMessage: 'Show referred by statuses' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
|
@ -46,10 +49,17 @@ const messages = defineMessages({
|
|||
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
visibilityMatchMessage: { id: 'visibility.match_message', defaultMessage: 'Do you want to match the visibility of the post to the reference?' },
|
||||
visibilityKeepMessage: { id: 'visibility.keep_message', defaultMessage: 'Do you want to keep the visibility of the post to the reference?' },
|
||||
visibilityChange: { id: 'visibility.change', defaultMessage: 'Change' },
|
||||
visibilityKeep: { id: 'visibility.keep', defaultMessage: 'Keep' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, { status }) => ({
|
||||
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
|
||||
referenceCountLimit: state.getIn(['compose', 'references']).size >= maxReferences,
|
||||
selected: state.getIn(['compose', 'references']).has(status.get('id')),
|
||||
composePrivacy: state.getIn(['compose', 'privacy']),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
|
@ -62,12 +72,19 @@ class ActionBar extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
referenceCountLimit: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
composePrivacy: PropTypes.string,
|
||||
onReply: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onQuote: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onAddReference: PropTypes.func,
|
||||
onRemoveReference: PropTypes.func,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onDirect: PropTypes.func.isRequired,
|
||||
onMemberList: PropTypes.func.isRequired,
|
||||
|
@ -95,6 +112,31 @@ class ActionBar extends React.PureComponent {
|
|||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleReferenceClick = (e) => {
|
||||
const { dispatch, intl, status, selected, composePrivacy, onAddReference, onRemoveReference } = this.props;
|
||||
const id = status.get('id');
|
||||
|
||||
if (selected) {
|
||||
onRemoveReference(id);
|
||||
} else {
|
||||
if (status.get('visibility') === 'private' && ['public', 'unlisted'].includes(composePrivacy)) {
|
||||
if (!addReferenceModal || e && e.shiftKey) {
|
||||
onAddReference(id, true);
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityMatchMessage : messages.visibilityKeepMessage),
|
||||
confirm: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityChange : messages.visibilityKeep),
|
||||
onConfirm: () => onAddReference(id, matchVisibilityOfReferences),
|
||||
secondary: intl.formatMessage(matchVisibilityOfReferences ? messages.visibilityKeep : messages.visibilityChange),
|
||||
onSecondary: () => onAddReference(id, !matchVisibilityOfReferences),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
onAddReference(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleQuoteClick = () => {
|
||||
this.props.onQuote(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
@ -224,6 +266,10 @@ class ActionBar extends React.PureComponent {
|
|||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/emoji_reactions`);
|
||||
}
|
||||
|
||||
handleReferredByStatuses = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}/referred_by`);
|
||||
}
|
||||
|
||||
handleEmojiPick = data => {
|
||||
const { addEmojiReaction, status } = this.props;
|
||||
addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
|
||||
|
@ -235,7 +281,7 @@ class ActionBar extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl } = this.props;
|
||||
const { status, relationship, intl, referenced, contextReferenced, referenceCountLimit } = this.props;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const mutingConversation = status.get('muted');
|
||||
|
@ -247,6 +293,7 @@ class ActionBar extends React.PureComponent {
|
|||
const bookmarked = status.get('bookmarked');
|
||||
const emoji_reactioned = status.get('emoji_reactioned');
|
||||
const reblogsCount = status.get('reblogs_count');
|
||||
const referredByCount = status.get('status_referred_by_count');
|
||||
const favouritesCount = status.get('favourites_count');
|
||||
const [ _, domain ] = account.get('acct').split('@');
|
||||
|
||||
|
@ -277,6 +324,10 @@ class ActionBar extends React.PureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.show_emoji_reactions), action: this.handleEmojiReactions });
|
||||
}
|
||||
|
||||
if (enableStatusReference && referredByCount > 0) {
|
||||
menu.push({ text: intl.formatMessage(messages.show_referred_by_statuses), action: this.handleReferredByStatuses });
|
||||
}
|
||||
|
||||
if (domain) {
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
|
||||
|
@ -361,9 +412,12 @@ class ActionBar extends React.PureComponent {
|
|||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||
}
|
||||
|
||||
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
|
||||
|
||||
return (
|
||||
<div className='detailed-status__action-bar'>
|
||||
<div className='detailed-status__button'><IconButton disabled={expired} title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||
{enableStatusReference && me && <div className='detailed-status__button'><IconButton className={classNames('link-icon', {referenced, 'context-referenced': contextReferenced})} animate disabled={referenceDisabled} active={referenced} pressed={referenced} title={intl.formatMessage(messages.reference)} icon='link' onClick={this.handleReferenceClick} /></div>}
|
||||
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={reblogged} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={favourited} disabled={!favourited && expired} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||
{show_quote_button && <div className='detailed-status__button'><IconButton disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>}
|
||||
|
|
|
@ -171,6 +171,24 @@ export default class Card extends React.PureComponent {
|
|||
this.setState({ revealed: true });
|
||||
}
|
||||
|
||||
handleOpen = e => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
const { card } = this.props;
|
||||
const account_id = card.get('account_id', null);
|
||||
const status_id = card.get('status_id', null);
|
||||
const url_suffix = card.get('url').endsWith('/references') ? '/references' : '';
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (status_id) {
|
||||
this.context.router.history.push(`/statuses/${status_id}${url_suffix}`);
|
||||
} else {
|
||||
this.context.router.history.push(`/accounts/${account_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderVideo () {
|
||||
const { card } = this.props;
|
||||
const content = { __html: addAutoPlay(card.get('html')) };
|
||||
|
@ -288,7 +306,7 @@ export default class Card extends React.PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
|
||||
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' onClick={card.get('account_id', null) ? this.handleOpen : null} ref={this.setRef}>
|
||||
{embed}
|
||||
{description}
|
||||
</a>
|
||||
|
|
|
@ -18,7 +18,7 @@ import Icon from 'mastodon/components/icon';
|
|||
import AnimatedNumber from 'mastodon/components/animated_number';
|
||||
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { enableReaction } from 'mastodon/initial_state';
|
||||
import { enableReaction, enableStatusReference } from 'mastodon/initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
|
@ -71,6 +71,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
quote_muted: PropTypes.bool,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
onOpenVideo: PropTypes.func.isRequired,
|
||||
|
@ -93,6 +95,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
emojiMap: ImmutablePropTypes.map,
|
||||
addEmojiReaction: PropTypes.func.isRequired,
|
||||
removeEmojiReaction: PropTypes.func.isRequired,
|
||||
onReference: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -177,22 +180,24 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||
const quote_muted = this.props.quote_muted
|
||||
const outerStyle = { boxSizing: 'border-box' };
|
||||
const { intl, compact, pictureInPicture } = this.props;
|
||||
const { intl, compact, pictureInPicture, referenced, contextReferenced } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let media = '';
|
||||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let reblogIcon = 'retweet';
|
||||
let favouriteLink = '';
|
||||
let emojiReactionLink = '';
|
||||
let media = '';
|
||||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let reblogIcon = 'retweet';
|
||||
let favouriteLink = '';
|
||||
let emojiReactionLink = '';
|
||||
let statusReferredByLink = '';
|
||||
|
||||
const reblogsCount = status.get('reblogs_count');
|
||||
const favouritesCount = status.get('favourites_count');
|
||||
const emojiReactionsCount = status.get('emoji_reactions_count');
|
||||
const statusReferredByCount = status.get('status_referred_by_count');
|
||||
|
||||
if (this.props.measureHeight) {
|
||||
outerStyle.height = `${this.state.height}px`;
|
||||
|
@ -387,41 +392,55 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
if (this.context.router) {
|
||||
favouriteLink = (
|
||||
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
|
||||
<Icon id='star' />
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={favouritesCount} />
|
||||
</span>
|
||||
</Link>
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
|
||||
<Icon id='star' />
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={favouritesCount} />
|
||||
</span>
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
favouriteLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id='star' />
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={favouritesCount} />
|
||||
</span>
|
||||
</a>
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id='star' />
|
||||
<span className='detailed-status__favorites'>
|
||||
<AnimatedNumber value={favouritesCount} />
|
||||
</span>
|
||||
</a>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.context.router) {
|
||||
if (enableReaction && this.context.router) {
|
||||
emojiReactionLink = (
|
||||
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
|
||||
<Icon id='smile-o' />
|
||||
<span className='detailed-status__emoji_reactions'>
|
||||
<AnimatedNumber value={emojiReactionsCount} />
|
||||
</span>
|
||||
</Link>
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
|
||||
<Icon id='smile-o' />
|
||||
<span className='detailed-status__emoji_reactions'>
|
||||
<AnimatedNumber value={emojiReactionsCount} />
|
||||
</span>
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
emojiReactionLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id='smile-o' />
|
||||
<span className='detailed-status__emoji_reactions'>
|
||||
<AnimatedNumber value={emojiReactionsCount} />
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
if (enableStatusReference && this.context.router) {
|
||||
statusReferredByLink = (
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<Link to={`/statuses/${status.get('id')}/referred_by`} className='detailed-status__link'>
|
||||
<Icon id='link' />
|
||||
<span className='detailed-status__status_referred_by'>
|
||||
<AnimatedNumber value={statusReferredByCount} />
|
||||
</span>
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -431,7 +450,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, 'detailed-status-with-expiration': expires_date, 'detailed-status-expired': expired })}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact, 'detailed-status-with-expiration': expires_date, 'detailed-status-expired': expired, referenced, 'context-referenced': contextReferenced })}>
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} className='detailed-status__display-name'>
|
||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||
|
@ -460,7 +479,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
</time>
|
||||
</span>
|
||||
}
|
||||
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionLink}
|
||||
{visibilityLink}{applicationLink}{reblogLink}{favouriteLink}{emojiReactionLink}{statusReferredByLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
39
app/javascript/mastodon/features/status/components/header.js
Normal file
39
app/javascript/mastodon/features/status/components/header.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
|
@ -1,10 +1,9 @@
|
|||
import Immutable from 'immutable';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchStatus } from '../../actions/statuses';
|
||||
import MissingIndicator from '../../components/missing_indicator';
|
||||
|
@ -28,6 +27,8 @@ import {
|
|||
quoteCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
addReference,
|
||||
removeReference,
|
||||
} from '../../actions/compose';
|
||||
import {
|
||||
muteStatus,
|
||||
|
@ -59,10 +60,11 @@ import { openModal } from '../../actions/modal';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { boostModal, deleteModal } from '../../initial_state';
|
||||
import { boostModal, deleteModal, enableStatusReference } from '../../initial_state';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import DetailedHeaderContaier from './containers/header_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
|
@ -83,12 +85,13 @@ const makeMapStateToProps = () => {
|
|||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
|
||||
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'inReplyTos']),
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = Immutable.List();
|
||||
let ancestorsIds = ImmutableList();
|
||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
|
@ -135,28 +138,33 @@ const makeMapStateToProps = () => {
|
|||
});
|
||||
}
|
||||
|
||||
return Immutable.List(descendantsIds);
|
||||
return ImmutableList(descendantsIds);
|
||||
});
|
||||
|
||||
const getReferencesIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'references']),
|
||||
], (statusId, contextReference) => {
|
||||
return ImmutableList(contextReference.get(statusId));
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
|
||||
let ancestorsIds = Immutable.List();
|
||||
let descendantsIds = Immutable.List();
|
||||
|
||||
if (status) {
|
||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||
}
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
const ancestorsIds = status ? getAncestorsIds(state, { id: status.get('in_reply_to_id') }) : ImmutableList();
|
||||
const descendantsIds = status ? getDescendantsIds(state, { id: status.get('id') }) : ImmutableList();
|
||||
const referencesIds = status ? getReferencesIds(state, { id: status.get('id') }) : ImmutableList();
|
||||
const id = status ? getProper(status).get('id') : null;
|
||||
|
||||
return {
|
||||
status,
|
||||
ancestorsIds,
|
||||
ancestorsIds: ancestorsIds.concat(referencesIds).sortBy(id => id),
|
||||
descendantsIds,
|
||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||
emojiMap: customEmojiMap(state),
|
||||
referenced: state.getIn(['compose', 'references']).has(id),
|
||||
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -177,6 +185,8 @@ class Status extends ImmutablePureComponent {
|
|||
status: ImmutablePropTypes.map,
|
||||
ancestorsIds: ImmutablePropTypes.list,
|
||||
descendantsIds: ImmutablePropTypes.list,
|
||||
referenced: PropTypes.bool,
|
||||
contextReferenced: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
|
@ -465,37 +475,34 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.dispatch(removeEmojiReaction(status));
|
||||
}
|
||||
|
||||
handleMoveUp = id => {
|
||||
handleAddReference = (id, change) => {
|
||||
this.props.dispatch(addReference(id, change));
|
||||
}
|
||||
|
||||
handleRemoveReference = (id) => {
|
||||
this.props.dispatch(removeReference(id));
|
||||
}
|
||||
|
||||
getCurrentStatusIndex = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const statusIds = ImmutableList([status.get('id')]);
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1, true);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
return ImmutableList().concat(ancestorsIds, statusIds, descendantsIds).indexOf(id);
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index, true);
|
||||
} else {
|
||||
this._selectChild(index - 1, true);
|
||||
}
|
||||
handleMoveUp = id => {
|
||||
const index = this.getCurrentStatusIndex(id);
|
||||
|
||||
if (index !== -1) {
|
||||
return this._selectChild(index - 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
const index = this.getCurrentStatusIndex(id);
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1, false);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||
} else {
|
||||
this._selectChild(index + 1, false);
|
||||
}
|
||||
if (index !== -1) {
|
||||
return this._selectChild(index + 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -556,7 +563,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap } = this.props;
|
||||
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap, referenced, contextReferenced } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (status === null) {
|
||||
|
@ -576,6 +583,8 @@ class Status extends ImmutablePureComponent {
|
|||
descendants = <div>{this.renderChildren(descendantsIds)}</div>;
|
||||
}
|
||||
|
||||
const referenceCount = enableStatusReference ? status.get('status_references_count', 0) - (status.get('status_reference_ids', ImmutableList()).includes(status.get('quote_id')) ? 1 : 0) : 0;
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -599,15 +608,23 @@ class Status extends ImmutablePureComponent {
|
|||
)}
|
||||
/>
|
||||
|
||||
<DetailedHeaderContaier statusId={status.get('id')} />
|
||||
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
||||
{ancestors}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper')} tabIndex='0' aria-label={textForScreenReader(intl, status, false)}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', {
|
||||
'detailed-status__wrapper-referenced': referenced,
|
||||
'detailed-status__wrapper-context-referenced': contextReferenced,
|
||||
'detailed-status__wrapper-reference': referenceCount > 0,
|
||||
})} tabIndex='0' aria-label={textForScreenReader(intl, status, false)}>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
status={status}
|
||||
referenced={referenced}
|
||||
contextReferenced={contextReferenced}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
onOpenVideoQuote={this.handleOpenVideoQuote}
|
||||
|
@ -628,6 +645,8 @@ class Status extends ImmutablePureComponent {
|
|||
<ActionBar
|
||||
key={`action-bar-${status.get('id')}`}
|
||||
status={status}
|
||||
referenced={referenced}
|
||||
contextReferenced={contextReferenced}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
|
@ -649,6 +668,8 @@ class Status extends ImmutablePureComponent {
|
|||
onEmbed={this.handleEmbed}
|
||||
addEmojiReaction={this.handleAddEmojiReaction}
|
||||
removeEmojiReaction={this.handleRemoveEmojiReaction}
|
||||
onAddReference={this.handleAddReference}
|
||||
onRemoveReference={this.handleRemoveReference}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
196
app/javascript/mastodon/features/status_references/index.js
Normal file
196
app/javascript/mastodon/features/status_references/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -5,12 +5,28 @@ import Audio from 'mastodon/features/audio';
|
|||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { addReference, removeReference } from 'mastodon/actions/compose';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
|
||||
});
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getProper = (status) => status.get('reblog', null) !== null && typeof status.get('reblog') === 'object' ? status.get('reblog') : status;
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.statusId });
|
||||
const id = status ? getProper(status).get('id') : null;
|
||||
|
||||
return {
|
||||
referenced: state.getIn(['compose', 'references']).has(id),
|
||||
contextReferenced: state.getIn(['compose', 'context_references']).has(id),
|
||||
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', props.statusId, 'account']), 'avatar_static']),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
class AudioModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -24,6 +40,14 @@ class AudioModal extends ImmutablePureComponent {
|
|||
onChangeBackgroundColor: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleAddReference = (id, change) => {
|
||||
this.props.dispatch(addReference(id, change));
|
||||
}
|
||||
|
||||
handleRemoveReference = (id) => {
|
||||
this.props.dispatch(removeReference(id));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, accountStaticAvatar, statusId, onClose } = this.props;
|
||||
const options = this.props.options || {};
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||
|
||||
export default class VideoModal extends ImmutablePureComponent {
|
||||
export default
|
||||
class VideoModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
|
|
|
@ -26,6 +26,7 @@ import PictureInPicture from 'mastodon/features/picture_in_picture';
|
|||
import {
|
||||
Compose,
|
||||
Status,
|
||||
StatusReferences,
|
||||
GettingStarted,
|
||||
KeyboardShortcuts,
|
||||
PublicTimeline,
|
||||
|
@ -50,6 +51,7 @@ import {
|
|||
FavouritedStatuses,
|
||||
BookmarkedStatuses,
|
||||
EmojiReactionedStatuses,
|
||||
ReferredByStatuses,
|
||||
ListTimeline,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
|
@ -188,9 +190,11 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
|
||||
<WrappedRoute path='/statuses/new' component={Compose} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/references' component={StatusReferences} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/referred_by' component={ReferredByStatuses} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/mentions' component={Mentions} content={children} />
|
||||
|
||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||
|
|
|
@ -50,6 +50,10 @@ export function Status () {
|
|||
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||
}
|
||||
|
||||
export function StatusReferences () {
|
||||
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
|
||||
}
|
||||
|
||||
export function GettingStarted () {
|
||||
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
|
||||
}
|
||||
|
@ -118,6 +122,10 @@ export function EmojiReactionedStatuses () {
|
|||
return import(/* webpackChunkName: "features/emoji_reactioned_statuses" */'../../emoji_reactioned_statuses');
|
||||
}
|
||||
|
||||
export function ReferredByStatuses () {
|
||||
return import(/* webpackChunkName: "features/referred_by_statuses" */'../../referred_by_statuses');
|
||||
}
|
||||
|
||||
export function Blocks () {
|
||||
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
|
||||
}
|
||||
|
@ -142,6 +150,10 @@ export function ReportModal () {
|
|||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
|
||||
}
|
||||
|
||||
export function ThumbnailGallery () {
|
||||
return import(/* webpackChunkName: "status/thumbnail_gallery" */'../../../components/thumbnail_gallery');
|
||||
}
|
||||
|
||||
export function MediaGallery () {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ export const unfollowModal = getMeta('unfollow_modal');
|
|||
export const unsubscribeModal = getMeta('unsubscribe_modal');
|
||||
export const boostModal = getMeta('boost_modal');
|
||||
export const deleteModal = getMeta('delete_modal');
|
||||
export const postReferenceModal = getMeta('post_reference_modal');
|
||||
export const addReferenceModal = getMeta('add_reference_modal');
|
||||
export const unselectReferenceModal = getMeta('unselect_reference_modal');
|
||||
export const me = getMeta('me');
|
||||
export const searchEnabled = getMeta('search_enabled');
|
||||
export const invitesEnabled = getMeta('invites_enabled');
|
||||
|
@ -43,5 +46,8 @@ export const enableReaction = getMeta('enable_reaction');
|
|||
export const show_reply_tree_button = getMeta('show_reply_tree_button');
|
||||
export const disable_joke_appearance = getMeta('disable_joke_appearance');
|
||||
export const new_features_policy = getMeta('new_features_policy');
|
||||
export const enableStatusReference = getMeta('enable_status_reference');
|
||||
export const maxReferences = initialState?.status_references?.max_references;
|
||||
export const matchVisibilityOfReferences = getMeta('match_visibility_of_references');
|
||||
|
||||
export default initialState;
|
||||
|
|
|
@ -146,6 +146,8 @@
|
|||
"confirmations.block.block_and_report": "Block & Report",
|
||||
"confirmations.block.confirm": "Block",
|
||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||
"confirmations.clear.confirm": "Clear all",
|
||||
"confirmations.clear.message": "Are you sure you want to clear all references?",
|
||||
"confirmations.delete.confirm": "Delete",
|
||||
"confirmations.delete.message": "Are you sure you want to delete this post?",
|
||||
"confirmations.delete_circle.confirm": "Delete",
|
||||
|
@ -159,6 +161,8 @@
|
|||
"confirmations.mute.confirm": "Mute",
|
||||
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
|
||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||
"confirmations.post_reference.confirm": "Post",
|
||||
"confirmations.post_reference.message": "It contains references, do you want to post it?",
|
||||
"confirmations.quote.confirm": "Quote",
|
||||
"confirmations.quote.message": "Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||
"confirmations.redraft.confirm": "Delete & redraft",
|
||||
|
@ -167,6 +171,8 @@
|
|||
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||
"confirmations.unfollow.confirm": "Unfollow",
|
||||
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
|
||||
"confirmations.unselect.confirm": "Unselect",
|
||||
"confirmations.unselect.message": "Are you sure you want to unselect a reference?",
|
||||
"confirmations.unsubscribe.confirm": "Unsubscribe",
|
||||
"confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?",
|
||||
"conversation.delete": "Delete conversation",
|
||||
|
@ -228,6 +234,7 @@
|
|||
"empty_column.mutes": "You haven't muted any users yet.",
|
||||
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
|
||||
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
|
||||
"empty_column.referred_by_statuses": "There are no referred by posts yet. When someone refers a post, it will appear here.",
|
||||
"empty_column.suggestions": "No one has suggestions yet.",
|
||||
"empty_column.trends": "No one has trends yet.",
|
||||
"error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
|
||||
|
@ -402,6 +409,7 @@
|
|||
"notification.emoji_reaction": "{name} reactioned your post",
|
||||
"notification.reblog": "{name} boosted your post",
|
||||
"notification.status": "{name} just posted",
|
||||
"notification.status_reference": "{name} referenced your post",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||
"notifications.column_settings.alert": "Desktop notifications",
|
||||
|
@ -419,6 +427,7 @@
|
|||
"notifications.column_settings.show": "Show in column",
|
||||
"notifications.column_settings.sound": "Play sound",
|
||||
"notifications.column_settings.status": "New posts:",
|
||||
"notifications.column_settings.status_reference": "Status reference:",
|
||||
"notifications.column_settings.unread_markers.category": "Unread notification markers",
|
||||
"notifications.filter.all": "All",
|
||||
"notifications.filter.boosts": "Boosts",
|
||||
|
@ -428,6 +437,7 @@
|
|||
"notifications.filter.polls": "Poll results",
|
||||
"notifications.filter.emoji_reactions": "Reactions",
|
||||
"notifications.filter.statuses": "Updates from people you follow",
|
||||
"notifications.filter.status_references": "Status references",
|
||||
"notifications.grant_permission": "Grant permission.",
|
||||
"notifications.group": "{count} notifications",
|
||||
"notifications.mark_as_read": "Mark every notification as read",
|
||||
|
@ -460,6 +470,8 @@
|
|||
"privacy.unlisted.long": "Visible for all, but not in public timelines",
|
||||
"privacy.unlisted.short": "Unlisted",
|
||||
"quote_indicator.cancel": "Cancel",
|
||||
"reference_stack.header": "References",
|
||||
"reference_stack.unselect": "Unselecting a post",
|
||||
"refresh": "Refresh",
|
||||
"regeneration_indicator.label": "Loading…",
|
||||
"regeneration_indicator.sublabel": "Your home feed is being prepared!",
|
||||
|
@ -493,6 +505,7 @@
|
|||
"status.block": "Block @{name}",
|
||||
"status.bookmark": "Bookmark",
|
||||
"status.cancel_reblog_private": "Unboost",
|
||||
"status.cancel_reference": "Remove reference",
|
||||
"status.cannot_quote": "This post cannot be quoted",
|
||||
"status.cannot_reblog": "This post cannot be boosted",
|
||||
"status.copy": "Copy link to post",
|
||||
|
@ -523,6 +536,8 @@
|
|||
"status.reblogged_by": "{name} boosted",
|
||||
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",
|
||||
"status.redraft": "Delete & re-draft",
|
||||
"status.reference": "Reference",
|
||||
"status.referred_by": "Referred",
|
||||
"status.remove_bookmark": "Remove bookmark",
|
||||
"status.reply": "Reply",
|
||||
"status.replyAll": "Reply to thread",
|
||||
|
@ -538,7 +553,9 @@
|
|||
"status.show_more_all": "Show more for all",
|
||||
"status.show_poll": "Show poll",
|
||||
"status.show_reblogs": "Show boosted users",
|
||||
"status.show_referred_by_statuses": "Show referred by statuses",
|
||||
"status.show_thread": "Show thread",
|
||||
"status.thread_with_references": "Thread",
|
||||
"status.uncached_media_warning": "Not available",
|
||||
"status.unlisted_quote": "Unlisted quote",
|
||||
"status.unmute_conversation": "Unmute conversation",
|
||||
|
@ -553,6 +570,12 @@
|
|||
"tabs_bar.local_timeline": "Local",
|
||||
"tabs_bar.notifications": "Notifications",
|
||||
"tabs_bar.search": "Search",
|
||||
"thread_mark.ancestor": "Has reference",
|
||||
"thread_mark.both": "Has reference and reply",
|
||||
"thread_mark.descendant": "Has reply",
|
||||
"thumbnail.type.audio": "(Audio)",
|
||||
"thumbnail.type.gif": "(GIF)",
|
||||
"thumbnail.type.video": "(Video)",
|
||||
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
|
||||
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
|
||||
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
|
||||
|
@ -598,5 +621,9 @@
|
|||
"video.mute": "Mute sound",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Play",
|
||||
"video.unmute": "Unmute sound"
|
||||
"video.unmute": "Unmute sound",
|
||||
"visibility.match_message": "Do you want to match the visibility of the post to the reference?",
|
||||
"visibility.keep_message": "Do you want to keep the visibility of the post to the reference?",
|
||||
"visibility.change": "Change",
|
||||
"visibility.keep": "Keep"
|
||||
}
|
||||
|
|
|
@ -146,6 +146,8 @@
|
|||
"confirmations.block.block_and_report": "ブロックし通報",
|
||||
"confirmations.block.confirm": "ブロック",
|
||||
"confirmations.block.message": "本当に{name}さんをブロックしますか?",
|
||||
"confirmations.clear.confirm": "すべての参照を解除",
|
||||
"confirmations.clear.message": "本当にすべての参照を解除しますか?",
|
||||
"confirmations.delete.confirm": "削除",
|
||||
"confirmations.delete.message": "本当に削除しますか?",
|
||||
"confirmations.delete_circle.confirm": "削除",
|
||||
|
@ -159,6 +161,8 @@
|
|||
"confirmations.mute.confirm": "ミュート",
|
||||
"confirmations.mute.explanation": "これにより相手の投稿と返信は見えなくなりますが、相手はあなたをフォローし続け投稿を見ることができます。",
|
||||
"confirmations.mute.message": "本当に{name}さんをミュートしますか?",
|
||||
"confirmations.post_reference.confirm": "投稿",
|
||||
"confirmations.post_reference.message": "参照を含んでいますが、投稿しますか?",
|
||||
"confirmations.quote.confirm": "引用",
|
||||
"confirmations.quote.message": "今引用すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
|
||||
"confirmations.redraft.confirm": "削除して下書きに戻す",
|
||||
|
@ -167,6 +171,8 @@
|
|||
"confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?",
|
||||
"confirmations.unfollow.confirm": "フォロー解除",
|
||||
"confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?",
|
||||
"confirmations.unselect.confirm": "選択解除",
|
||||
"confirmations.unselect.message": "本当に参照の選択を解除しますか?",
|
||||
"confirmations.unsubscribe.confirm": "購読解除",
|
||||
"confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?",
|
||||
"conversation.delete": "会話を削除",
|
||||
|
@ -222,12 +228,12 @@
|
|||
"empty_column.hashtag": "このハッシュタグはまだ使われていません。",
|
||||
"empty_column.home": "ホームタイムラインはまだ空っぽです。誰かフォローして埋めてみましょう。 {suggestions}",
|
||||
"empty_column.home.suggestions": "おすすめを見る",
|
||||
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
|
||||
"empty_column.limited": "まだ誰からも公開範囲が限定された投稿を受け取っていません。",
|
||||
"empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しい投稿をするとここに表示されます。",
|
||||
"empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。",
|
||||
"empty_column.mutes": "まだ誰もミュートしていません。",
|
||||
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
|
||||
"empty_column.referred_by_statuses": "まだ、参照している投稿はありません。誰かが投稿を参照すると、ここに表示されます。",
|
||||
"empty_column.suggestions": "まだおすすめできるユーザーがいません。",
|
||||
"empty_column.trends": "まだ何もトレンドがありません。",
|
||||
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう",
|
||||
|
@ -403,6 +409,7 @@
|
|||
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
|
||||
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
|
||||
"notification.status": "{name}さんが投稿しました",
|
||||
"notification.status_reference": "{name}さんがあなたの投稿を参照しました",
|
||||
"notifications.clear": "通知を消去",
|
||||
"notifications.clear_confirmation": "本当に通知を消去しますか?",
|
||||
"notifications.column_settings.alert": "デスクトップ通知",
|
||||
|
@ -420,6 +427,7 @@
|
|||
"notifications.column_settings.show": "カラムに表示",
|
||||
"notifications.column_settings.sound": "通知音を再生",
|
||||
"notifications.column_settings.status": "新しい投稿:",
|
||||
"notifications.column_settings.status_reference": "投稿の参照:",
|
||||
"notifications.column_settings.unread_markers.category": "未読マーカー",
|
||||
"notifications.filter.all": "すべて",
|
||||
"notifications.filter.boosts": "ブースト",
|
||||
|
@ -429,6 +437,7 @@
|
|||
"notifications.filter.polls": "アンケート結果",
|
||||
"notifications.filter.emoji_reactions": "リアクション",
|
||||
"notifications.filter.statuses": "フォローしている人の新着情報",
|
||||
"notifications.filter.status_references": "投稿の参照",
|
||||
"notifications.grant_permission": "権限の付与",
|
||||
"notifications.group": "{count} 件の通知",
|
||||
"notifications.mark_as_read": "すべて既読にする",
|
||||
|
@ -461,6 +470,8 @@
|
|||
"privacy.unlisted.long": "誰でも閲覧可、公開TLに非表示",
|
||||
"privacy.unlisted.short": "未収載",
|
||||
"quote_indicator.cancel": "キャンセル",
|
||||
"reference_stack.header": "参照",
|
||||
"reference_stack.unselect": "投稿を選択解除",
|
||||
"refresh": "更新",
|
||||
"regeneration_indicator.label": "読み込み中…",
|
||||
"regeneration_indicator.sublabel": "ホームタイムラインは準備中です!",
|
||||
|
@ -494,6 +505,7 @@
|
|||
"status.block": "@{name}さんをブロック",
|
||||
"status.bookmark": "ブックマーク",
|
||||
"status.cancel_reblog_private": "ブースト解除",
|
||||
"status.cancel_reference": "参照解除",
|
||||
"status.cannot_quote": "この投稿は引用できません",
|
||||
"status.cannot_reblog": "この投稿はブーストできません",
|
||||
"status.copy": "投稿へのリンクをコピー",
|
||||
|
@ -524,6 +536,8 @@
|
|||
"status.reblogged_by": "{name}さんがブースト",
|
||||
"status.reblogs.empty": "まだ誰もブーストしていません。ブーストされるとここに表示されます。",
|
||||
"status.redraft": "削除して下書きに戻す",
|
||||
"status.reference": "参照",
|
||||
"status.referred_by": "参照",
|
||||
"status.remove_bookmark": "ブックマークを削除",
|
||||
"status.reply": "返信",
|
||||
"status.replyAll": "全員に返信",
|
||||
|
@ -539,7 +553,9 @@
|
|||
"status.show_more_all": "全て見る",
|
||||
"status.show_poll": "アンケートを表示",
|
||||
"status.show_reblogs": "ブーストしたユーザーを表示",
|
||||
"status.show_referred_by_statuses": "参照している投稿を表示",
|
||||
"status.show_thread": "スレッドを表示",
|
||||
"status.thread_with_references": "スレッド",
|
||||
"status.uncached_media_warning": "利用できません",
|
||||
"status.unlisted_quote": "未収載の引用",
|
||||
"status.unmute_conversation": "会話のミュートを解除",
|
||||
|
@ -554,6 +570,12 @@
|
|||
"tabs_bar.local_timeline": "ローカル",
|
||||
"tabs_bar.notifications": "通知",
|
||||
"tabs_bar.search": "検索",
|
||||
"thread_mark.ancestor": "参照あり",
|
||||
"thread_mark.both": "参照・返信あり",
|
||||
"thread_mark.descendant": "返信あり",
|
||||
"thumbnail.type.audio": "(音声)",
|
||||
"thumbnail.type.gif": "(GIF)",
|
||||
"thumbnail.type.video": "(動画)",
|
||||
"time_remaining.days": "残り{number}日",
|
||||
"time_remaining.hours": "残り{number}時間",
|
||||
"time_remaining.minutes": "残り{number}分",
|
||||
|
@ -599,5 +621,9 @@
|
|||
"video.mute": "ミュート",
|
||||
"video.pause": "一時停止",
|
||||
"video.play": "再生",
|
||||
"video.unmute": "ミュートを解除する"
|
||||
"video.unmute": "ミュートを解除する",
|
||||
"visibility.match_message": "投稿の公開範囲を参照先に合わせますか?",
|
||||
"visibility.keep_message": "投稿の公開範囲を参照先に合わせず維持しますか?",
|
||||
"visibility.change": "変更",
|
||||
"visibility.keep": "維持"
|
||||
}
|
||||
|
|
|
@ -50,11 +50,14 @@ import {
|
|||
COMPOSE_SCHEDULED_CHANGE,
|
||||
COMPOSE_EXPIRES_CHANGE,
|
||||
COMPOSE_EXPIRES_ACTION_CHANGE,
|
||||
COMPOSE_REFERENCE_ADD,
|
||||
COMPOSE_REFERENCE_REMOVE,
|
||||
COMPOSE_REFERENCE_RESET,
|
||||
} from '../actions/compose';
|
||||
import { TIMELINE_DELETE, TIMELINE_EXPIRE } from '../actions/timelines';
|
||||
import { STORE_HYDRATE } from '../actions/store';
|
||||
import { REDRAFT } from '../actions/statuses';
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
import uuid from '../uuid';
|
||||
import { me } from '../initial_state';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
|
@ -103,6 +106,8 @@ const initialState = ImmutableMap({
|
|||
scheduled: null,
|
||||
expires: null,
|
||||
expires_action: 'mark',
|
||||
references: ImmutableSet(),
|
||||
context_references: ImmutableSet(),
|
||||
});
|
||||
|
||||
const initialPoll = ImmutableMap({
|
||||
|
@ -155,6 +160,8 @@ const clearAll = state => {
|
|||
map.set('scheduled', null);
|
||||
map.set('expires', null);
|
||||
map.set('expires_action', 'mark');
|
||||
map.update('references', set => set.clear());
|
||||
map.update('context_references', set => set.clear());
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -374,6 +381,7 @@ export default function compose(state = initialState, action) {
|
|||
map.set('scheduled', null);
|
||||
map.set('expires', null);
|
||||
map.set('expires_action', 'mark');
|
||||
map.update('context_references', set => set.clear().concat(action.context_references));
|
||||
|
||||
if (action.status.get('spoiler_text').length > 0) {
|
||||
map.set('spoiler', true);
|
||||
|
@ -397,6 +405,7 @@ export default function compose(state = initialState, action) {
|
|||
map.set('scheduled', null);
|
||||
map.set('expires', null);
|
||||
map.set('expires_action', 'mark');
|
||||
map.update('context_references', set => set.clear().add(action.status.get('id')));
|
||||
|
||||
if (action.status.get('spoiler_text').length > 0) {
|
||||
map.set('spoiler', true);
|
||||
|
@ -425,6 +434,10 @@ export default function compose(state = initialState, action) {
|
|||
map.set('scheduled', null);
|
||||
map.set('expires', null);
|
||||
map.set('expires_action', 'mark');
|
||||
map.update('context_references', set => set.clear());
|
||||
if (action.type == COMPOSE_RESET) {
|
||||
map.update('references', set => set.clear());
|
||||
}
|
||||
});
|
||||
case COMPOSE_SUBMIT_REQUEST:
|
||||
return state.set('is_submitting', true);
|
||||
|
@ -539,6 +552,8 @@ export default function compose(state = initialState, action) {
|
|||
map.set('scheduled', action.status.get('scheduled_at'));
|
||||
map.set('expires', action.status.get('expires_at') ? format(parseISO(action.status.get('expires_at')), 'yyyy-MM-dd HH:mm') : null);
|
||||
map.set('expires_action', action.status.get('expires_action') ?? 'mark');
|
||||
map.update('references', set => set.clear().concat(action.status.get('status_reference_ids')));
|
||||
map.update('context_references', set => set.clear().concat(action.context_references));
|
||||
|
||||
if (action.status.get('spoiler_text').length > 0) {
|
||||
map.set('spoiler', true);
|
||||
|
@ -583,6 +598,12 @@ export default function compose(state = initialState, action) {
|
|||
return state.set('expires', action.value);
|
||||
case COMPOSE_EXPIRES_ACTION_CHANGE:
|
||||
return state.set('expires_action', action.value);
|
||||
case COMPOSE_REFERENCE_ADD:
|
||||
return state.update('references', set => set.add(action.id));
|
||||
case COMPOSE_REFERENCE_REMOVE:
|
||||
return state.update('references', set => set.delete(action.id));
|
||||
case COMPOSE_REFERENCE_RESET:
|
||||
return state.update('references', set => set.clear());
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -10,33 +10,45 @@ import compareId from '../compare_id';
|
|||
const initialState = ImmutableMap({
|
||||
inReplyTos: ImmutableMap(),
|
||||
replies: ImmutableMap(),
|
||||
references: ImmutableMap(),
|
||||
});
|
||||
|
||||
const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
|
||||
const normalizeContext = (immutableState, id, ancestors, descendants, references) => immutableState.withMutations(state => {
|
||||
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
|
||||
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
|
||||
function addReply({ id, in_reply_to_id }) {
|
||||
if (in_reply_to_id && !inReplyTos.has(id)) {
|
||||
state.update('references', immutableReferences => immutableReferences.withMutations(refs => {
|
||||
function addReply({ id, in_reply_to_id }) {
|
||||
if (in_reply_to_id && !inReplyTos.has(id)) {
|
||||
|
||||
replies.update(in_reply_to_id, ImmutableList(), siblings => {
|
||||
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
|
||||
return siblings.insert(index + 1, id);
|
||||
});
|
||||
replies.update(in_reply_to_id, ImmutableList(), siblings => {
|
||||
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
|
||||
return siblings.insert(index + 1, id);
|
||||
});
|
||||
|
||||
inReplyTos.set(id, in_reply_to_id);
|
||||
inReplyTos.set(id, in_reply_to_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We know in_reply_to_id of statuses but `id` itself.
|
||||
// So we assume that the status of the id replies to last ancestors.
|
||||
// We know in_reply_to_id of statuses but `id` itself.
|
||||
// So we assume that the status of the id replies to last ancestors.
|
||||
|
||||
ancestors.forEach(addReply);
|
||||
ancestors.forEach(addReply);
|
||||
|
||||
if (ancestors[0]) {
|
||||
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
|
||||
}
|
||||
if (ancestors[0]) {
|
||||
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
|
||||
}
|
||||
|
||||
descendants.forEach(addReply);
|
||||
descendants.forEach(addReply);
|
||||
|
||||
if (references.length > 0) {
|
||||
const referencesIds = ImmutableList();
|
||||
refs.set(id, referencesIds.withMutations(refIds => {
|
||||
references.forEach(reference => {
|
||||
refIds.push(reference.id);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
});
|
||||
|
@ -44,23 +56,26 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab
|
|||
const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
|
||||
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
|
||||
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
|
||||
ids.forEach(id => {
|
||||
const inReplyToIdOfId = inReplyTos.get(id);
|
||||
const repliesOfId = replies.get(id);
|
||||
const siblings = replies.get(inReplyToIdOfId);
|
||||
state.update('references', immutableReferences => immutableReferences.withMutations(refs => {
|
||||
ids.forEach(id => {
|
||||
const inReplyToIdOfId = inReplyTos.get(id);
|
||||
const repliesOfId = replies.get(id);
|
||||
const siblings = replies.get(inReplyToIdOfId);
|
||||
|
||||
if (siblings) {
|
||||
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
|
||||
}
|
||||
if (siblings) {
|
||||
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
|
||||
}
|
||||
|
||||
|
||||
if (repliesOfId) {
|
||||
repliesOfId.forEach(reply => inReplyTos.delete(reply));
|
||||
}
|
||||
if (repliesOfId) {
|
||||
repliesOfId.forEach(reply => inReplyTos.delete(reply));
|
||||
}
|
||||
|
||||
inReplyTos.delete(id);
|
||||
replies.delete(id);
|
||||
});
|
||||
inReplyTos.delete(id);
|
||||
replies.delete(id);
|
||||
refs.delete(id);
|
||||
});
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
});
|
||||
|
@ -95,7 +110,7 @@ export default function replies(state = initialState, action) {
|
|||
case ACCOUNT_MUTE_SUCCESS:
|
||||
return filterContexts(state, action.relationship, action.statuses);
|
||||
case CONTEXT_FETCH_SUCCESS:
|
||||
return normalizeContext(state, action.id, action.ancestors, action.descendants);
|
||||
return normalizeContext(state, action.id, action.ancestors, action.descendants, action.references);
|
||||
case TIMELINE_DELETE:
|
||||
case TIMELINE_EXPIRE:
|
||||
return deleteFromContexts(state, [action.id]);
|
||||
|
|
|
@ -7,6 +7,7 @@ import { loadingBarReducer } from 'react-redux-loading-bar';
|
|||
import modal from './modal';
|
||||
import user_lists from './user_lists';
|
||||
import domain_lists from './domain_lists';
|
||||
import status_status_lists from './status_status_lists';
|
||||
import accounts from './accounts';
|
||||
import accounts_counters from './accounts_counters';
|
||||
import statuses from './statuses';
|
||||
|
@ -55,6 +56,7 @@ const reducers = {
|
|||
user_lists,
|
||||
domain_lists,
|
||||
status_lists,
|
||||
status_status_lists,
|
||||
accounts,
|
||||
accounts_counters,
|
||||
statuses,
|
||||
|
|
|
@ -54,6 +54,7 @@ const initialState = ImmutableMap({
|
|||
poll: false,
|
||||
status: false,
|
||||
emoji_reaction: false,
|
||||
status_reference: false,
|
||||
}),
|
||||
|
||||
quickFilter: ImmutableMap({
|
||||
|
@ -74,6 +75,7 @@ const initialState = ImmutableMap({
|
|||
poll: true,
|
||||
status: true,
|
||||
emoji_reaction: true,
|
||||
status_reference: false,
|
||||
}),
|
||||
|
||||
sounds: ImmutableMap({
|
||||
|
@ -85,6 +87,7 @@ const initialState = ImmutableMap({
|
|||
poll: true,
|
||||
status: true,
|
||||
emoji_reaction: true,
|
||||
status_reference: false,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
|
|
@ -37,6 +37,12 @@ import {
|
|||
UNPIN_SUCCESS,
|
||||
} from '../actions/interactions';
|
||||
|
||||
const initialListState = ImmutableMap({
|
||||
next: null,
|
||||
isLoading: false,
|
||||
items: ImmutableList(),
|
||||
});
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
favourites: ImmutableMap({
|
||||
next: null,
|
||||
|
|
44
app/javascript/mastodon/reducers/status_status_lists.js
Normal file
44
app/javascript/mastodon/reducers/status_status_lists.js
Normal 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;
|
||||
}
|
||||
};
|
|
@ -408,7 +408,8 @@ html {
|
|||
border-top: 0;
|
||||
}
|
||||
|
||||
.icon-with-badge__badge {
|
||||
.icon-with-badge__badge,
|
||||
.status__thread_mark {
|
||||
border-color: $white;
|
||||
}
|
||||
|
||||
|
@ -748,7 +749,8 @@ html {
|
|||
}
|
||||
|
||||
.public-layout {
|
||||
.account__section-headline {
|
||||
.account__section-headline,
|
||||
.status__section-headline {
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
|
@ -837,7 +839,8 @@ html {
|
|||
}
|
||||
|
||||
.notification__filter-bar button.active::after,
|
||||
.account__section-headline a.active::after {
|
||||
.account__section-headline a.active::after,
|
||||
.status__section-headline a.active::after {
|
||||
border-color: transparent transparent $white;
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.account__section-headline {
|
||||
.account__section-headline,
|
||||
.status__section-headline {
|
||||
@include shadow-1dp;
|
||||
border-radius: $card-radius $card-radius 0 0;
|
||||
}
|
||||
|
|
|
@ -983,7 +983,7 @@
|
|||
|
||||
.status__content__read-more-button {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: lighten($ui-highlight-color, 8%);
|
||||
border: 0;
|
||||
|
@ -1184,6 +1184,7 @@
|
|||
|
||||
.status__relative-time,
|
||||
.status__visibility-icon,
|
||||
.status__thread_mark,
|
||||
.status__expiration-time,
|
||||
.notification__relative_time {
|
||||
color: $dark-text-color;
|
||||
|
@ -1213,6 +1214,27 @@
|
|||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
.status__thread_mark {
|
||||
background: $ui-highlight-color;
|
||||
border: 2px solid lighten($ui-base-color, 8%);
|
||||
padding: 0px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
color: $primary-text-color;
|
||||
margin-inline-start: 2px;
|
||||
|
||||
&.status__thread_mark-both,
|
||||
&.status__thread_mark-ancenstor.status__thread_mark-descendant {
|
||||
background: $active-passive-text-color;
|
||||
}
|
||||
|
||||
&.status__thread_mark-descendant {
|
||||
background: $passive-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.status__info .status__display-name {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
@ -1327,6 +1349,7 @@
|
|||
.detailed-status {
|
||||
background: lighten($ui-base-color, 4%);
|
||||
padding: 14px 10px;
|
||||
position: relative;
|
||||
|
||||
&--flex {
|
||||
display: flex;
|
||||
|
@ -1360,6 +1383,28 @@
|
|||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.referenced,
|
||||
&.context-referenced {
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.context-referenced::before {
|
||||
border-left: 4px solid darken($passive-text-color, 20%);
|
||||
}
|
||||
|
||||
&.referenced::before {
|
||||
border-left: 4px solid $passive-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-status__meta {
|
||||
|
@ -1385,6 +1430,7 @@
|
|||
|
||||
.detailed-status__favorites,
|
||||
.detailed-status__emoji_reactions,
|
||||
.detailed-status__status_referred_by,
|
||||
.detailed-status__reblogs {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
|
@ -2868,7 +2914,7 @@ a.account__display-name {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 10px);
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.navigation-bar {
|
||||
padding-top: 20px;
|
||||
|
@ -2882,7 +2928,7 @@ a.account__display-name {
|
|||
}
|
||||
|
||||
.compose-form {
|
||||
flex: 1;
|
||||
flex: 1 0 auto;
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -6545,8 +6591,16 @@ a.status-card.compact:hover {
|
|||
}
|
||||
}
|
||||
|
||||
.status__section-headline + .activity-stream {
|
||||
border-top: none;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.notification__filter-bar,
|
||||
.account__section-headline,
|
||||
.status__section-headline,
|
||||
.detailed-status__section-headline,
|
||||
.status-reactioned__section-headline {
|
||||
background: darken($ui-base-color, 4%);
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
|
@ -7931,7 +7985,8 @@ noscript {
|
|||
}
|
||||
|
||||
.notification,
|
||||
.status__wrapper {
|
||||
.status__wrapper,
|
||||
.mini-status__wrapper {
|
||||
position: relative;
|
||||
|
||||
&.unread {
|
||||
|
@ -7947,6 +8002,29 @@ noscript {
|
|||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.status__wrapper-referenced,
|
||||
&.status__wrapper-context-referenced {
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-left: 4px solid $highlight-text-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.status__wrapper-context-referenced::before {
|
||||
border-left: 4px solid darken($passive-text-color, 20%);
|
||||
}
|
||||
|
||||
&.status__wrapper-referenced::before {
|
||||
border-left: 4px solid $passive-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.picture-in-picture {
|
||||
|
@ -8114,3 +8192,188 @@ noscript {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
.reference-link-inline {
|
||||
display: none;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.public-layout,
|
||||
.status__wrapper-reference,
|
||||
.detailed-status__wrapper-reference {
|
||||
.reference-link-inline {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.reference-stack {
|
||||
margin: 10px 0;
|
||||
|
||||
.stack-header {
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
outline: 0;
|
||||
min-height: 36px;
|
||||
|
||||
& :first-child {
|
||||
border-start-start-radius: 4px;
|
||||
}
|
||||
|
||||
& :last-child {
|
||||
border-start-end-radius: 4px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
flex: 0 0 auto;
|
||||
|
||||
border: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 15px;
|
||||
|
||||
&:hover {
|
||||
color: lighten($darker-text-color, 7%);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $primary-text-color;
|
||||
background: lighten($ui-base-color, 8%);
|
||||
|
||||
&:hover {
|
||||
color: $primary-text-color;
|
||||
background: lighten($ui-base-color, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
flex: 1 1 auto;
|
||||
text-align: start;
|
||||
|
||||
.icon-with-badge {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reference-stack__list {
|
||||
background: $ui-base-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-status {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
cursor: auto;
|
||||
|
||||
&__account {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
|
||||
&__text {
|
||||
overflow-y: hidden;
|
||||
max-height: 2.5em;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
&__unselect {
|
||||
flex: 0 0 auto;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-gallery {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
min-height: auto;
|
||||
box-sizing: border-box;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thumbnail-gallery__item {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
|
||||
&-thumbnail {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: $secondary-text-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
object-fit: cover;
|
||||
font-family: 'object-fit: cover;';
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-gallery__type {
|
||||
flex: 1 0 auto;
|
||||
height: 20px;
|
||||
color: $darker-text-color;
|
||||
}
|
||||
|
||||
.thumbnail-gallery__preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
background: $base-overlay-background;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail-gallery__gifv {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumbnail-gallery__item-gifv-thumbnail {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -773,7 +773,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.account__section-headline {
|
||||
.account__section-headline,
|
||||
.status__section-headline {
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint) {
|
||||
|
|
|
@ -270,6 +270,7 @@ body.rtl {
|
|||
|
||||
.detailed-status__favorites,
|
||||
.detailed-status__emoji_reactions,
|
||||
.detailed-status__status_referred_by,
|
||||
.detailed-status__reblogs {
|
||||
margin-left: 0;
|
||||
margin-right: 6px;
|
||||
|
|
|
@ -84,6 +84,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
attach_tags(@status)
|
||||
end
|
||||
|
||||
resolve_references(@status, @mentions, @object['references'])
|
||||
resolve_thread(@status)
|
||||
fetch_replies(@status)
|
||||
distribute(@status)
|
||||
|
@ -243,6 +244,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
Rails.logger.warn "Error storing emoji: #{e}"
|
||||
end
|
||||
|
||||
def resolve_references(status, mentions, collection)
|
||||
references = []
|
||||
references = ActivityPub::FetchReferencesService.new.call(status, collection) unless collection.nil?
|
||||
ProcessStatusReferenceService.new.call(status, mentions: mentions, urls: (references + [quote_uri]).compact.uniq)
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return [] if @object['attachment'].nil?
|
||||
|
||||
|
@ -553,6 +560,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
retry
|
||||
end
|
||||
|
||||
def quote_uri
|
||||
ActivityPub::TagManager.instance.uri_for(quote) if quote
|
||||
end
|
||||
|
||||
def quote
|
||||
@quote ||= quote_from_url(@object['quoteUri'] || @object['_misskey_quote'])
|
||||
end
|
||||
|
|
|
@ -72,6 +72,12 @@ class ActivityPub::TagManager
|
|||
account_status_replies_url(target.account, target, page_params)
|
||||
end
|
||||
|
||||
def references_uri_for(target, page_params = nil)
|
||||
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
|
||||
|
||||
account_status_references_url(target.account, target, page_params)
|
||||
end
|
||||
|
||||
# Primary audience of a status
|
||||
# Public statuses go out to primarily the public collection
|
||||
# Unlisted and private statuses go out primarily to the followers collection
|
||||
|
|
|
@ -59,7 +59,7 @@ class EntityCache
|
|||
account
|
||||
end
|
||||
|
||||
def holding_status_and_account(url)
|
||||
def holding_status(url)
|
||||
return Rails.cache.read(to_key(:holding_status, url)) if Rails.cache.exist?(to_key(:holding_status, url))
|
||||
|
||||
status = begin
|
||||
|
@ -72,11 +72,13 @@ class EntityCache
|
|||
nil
|
||||
end
|
||||
|
||||
account = status&.account
|
||||
update_holding_status(url, status)
|
||||
|
||||
Rails.cache.write(to_key(:holding_status, url), [status, account], expires_in: account.nil? ? MIN_EXPIRATION : MAX_EXPIRATION)
|
||||
status
|
||||
end
|
||||
|
||||
[status, account]
|
||||
def update_holding_status(url, status)
|
||||
Rails.cache.write(to_key(:holding_status, url), status, expires_in: status&.account.nil? ? MIN_EXPIRATION : MAX_EXPIRATION)
|
||||
end
|
||||
|
||||
def to_key(type, *ids)
|
||||
|
|
|
@ -50,6 +50,8 @@ class FeedManager
|
|||
filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]))
|
||||
when :mentions
|
||||
filter_from_mentions?(status, receiver.id)
|
||||
when :status_references
|
||||
filter_from_status_references?(status, receiver.id)
|
||||
else
|
||||
false
|
||||
end
|
||||
|
@ -425,6 +427,28 @@ class FeedManager
|
|||
should_filter
|
||||
end
|
||||
|
||||
# Check if status should not be added to the status reference feed
|
||||
# @see NotifyService
|
||||
# @param [Status] status
|
||||
# @param [Integer] receiver_id
|
||||
# @return [Boolean]
|
||||
def filter_from_status_references?(status, receiver_id)
|
||||
return true if receiver_id == status.account_id
|
||||
return true if phrase_filtered?(status, receiver_id, :notifications)
|
||||
return true unless StatusPolicy.new(Account.find(receiver_id), status).subscribe?
|
||||
|
||||
# This filter is called from NotifyService, but already after the sender of
|
||||
# the notification has been checked for mute/block. Therefore, it's not
|
||||
# necessary to check the author of the toot for mute/block again
|
||||
check_for_blocks = status.active_mentions.pluck(:account_id)
|
||||
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
|
||||
|
||||
should_filter = blocks_or_mutes?(receiver_id, check_for_blocks, :status_references) # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
|
||||
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
|
||||
|
||||
should_filter
|
||||
end
|
||||
|
||||
# Check if status should not be added to the list feed
|
||||
# @param [Status] status
|
||||
# @param [List] list
|
||||
|
|
|
@ -27,6 +27,7 @@ class Formatter
|
|||
unless status.local?
|
||||
html = reformat(raw_content)
|
||||
html = apply_inner_link(html)
|
||||
html = apply_reference_link(html, status)
|
||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html = nyaize_html(html) if options[:nyaize]
|
||||
return html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
|
@ -41,6 +42,7 @@ class Formatter
|
|||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||
html = simple_format(html, {}, sanitize: false)
|
||||
html = quotify(html, status) if status.quote? && !options[:escape_quotify]
|
||||
html = add_compatible_reference_link(html, status) if status.references.exists?
|
||||
html = nyaize_html(html) if options[:nyaize]
|
||||
html = html.delete("\n")
|
||||
|
||||
|
@ -68,6 +70,7 @@ class Formatter
|
|||
return status.text if status.local?
|
||||
|
||||
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
|
||||
text = remove_reference_link(text)
|
||||
strip_tags(text)
|
||||
end
|
||||
|
||||
|
@ -212,6 +215,12 @@ class Formatter
|
|||
html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{link}</span>\\1")
|
||||
end
|
||||
|
||||
def add_compatible_reference_link(html, status)
|
||||
url = references_short_account_status_url(status.account, status)
|
||||
link = "<a href=\"#{url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"status-link unhandled-link\" data-status-id=\"#{status.id}\">#{I18n.t('status_references.link_text')}</a>"
|
||||
html.sub(/<\/p>\z/, "<span class=\"reference-link-inline\"> #{link}</span></p>")
|
||||
end
|
||||
|
||||
def nyaize_html(html)
|
||||
inside_anchor = false
|
||||
|
||||
|
@ -293,8 +302,9 @@ class Formatter
|
|||
|
||||
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
|
||||
|
||||
status, account = url_to_holding_status_and_account(url.normalize.to_s)
|
||||
account = url_to_holding_account(url.normalize.to_s) if status.nil?
|
||||
status = url_to_holding_status(url.normalize.to_s)
|
||||
account = status&.account
|
||||
account = url_to_holding_account(url.normalize.to_s) if status.nil?
|
||||
|
||||
if status.present? && account.present?
|
||||
html_attrs[:class] = class_append(html_attrs[:class], ['status-url-link'])
|
||||
|
@ -315,8 +325,9 @@ class Formatter
|
|||
def apply_inner_link(html)
|
||||
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
|
||||
doc.css('a').map do |x|
|
||||
status, account = url_to_holding_status_and_account(x['href'])
|
||||
account = url_to_holding_account(x['href']) if status.nil?
|
||||
status = url_to_holding_status(x['href'])
|
||||
account = status&.account
|
||||
account = url_to_holding_account(x['href']) if status.nil?
|
||||
|
||||
if status.present? && account.present?
|
||||
x.add_class('status-url-link')
|
||||
|
@ -333,6 +344,44 @@ class Formatter
|
|||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def remove_reference_link(html)
|
||||
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
|
||||
doc.at_css('span.reference-link-inline')&.unlink
|
||||
html = doc.at_css('body')&.inner_html || ''
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def apply_reference_link(html, status)
|
||||
doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
|
||||
|
||||
reference_link_url = nil
|
||||
|
||||
doc.at_css('span.reference-link-inline').tap do |x|
|
||||
if x.present?
|
||||
reference_link_url = x.at_css('a')&.attr('href')
|
||||
x.unlink
|
||||
end
|
||||
end
|
||||
|
||||
if status.references.exists?
|
||||
ref_span = Nokogiri::XML::Node.new("span", doc)
|
||||
ref_anchor = Nokogiri::XML::Node.new("a", doc)
|
||||
ref_anchor.add_class('status-link unhandled-link')
|
||||
ref_anchor['href'] = reference_link_url || status.url
|
||||
ref_anchor['target'] = '_blank'
|
||||
ref_anchor['rel'] = 'noopener noreferrer'
|
||||
ref_anchor['data-status-id'] = status.id
|
||||
ref_anchor.content = I18n.t('status_references.link_text')
|
||||
ref_span.content = ' '
|
||||
ref_span.add_class('reference-link-inline')
|
||||
ref_span.add_child(ref_anchor)
|
||||
(doc.at_css('body > p:last-child') || doc.at_css('body'))&.add_child(ref_span)
|
||||
end
|
||||
|
||||
html = doc.at_css('body')&.inner_html || ''
|
||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||
end
|
||||
|
||||
def url_to_holding_account(url)
|
||||
url = url.split('#').first
|
||||
|
||||
|
@ -341,12 +390,12 @@ class Formatter
|
|||
EntityCache.instance.holding_account(url)
|
||||
end
|
||||
|
||||
def url_to_holding_status_and_account(url)
|
||||
def url_to_holding_status(url)
|
||||
url = url.split('#').first
|
||||
|
||||
return if url.nil?
|
||||
|
||||
EntityCache.instance.holding_status_and_account(url)
|
||||
EntityCache.instance.holding_status(url)
|
||||
end
|
||||
|
||||
def link_to_mention(entity, linkable_accounts, options = {})
|
||||
|
|
|
@ -21,6 +21,8 @@ class InlineRenderer
|
|||
serializer = REST::ReactionSerializer
|
||||
when :emoji_reaction
|
||||
serializer = REST::GroupedEmojiReactionSerializer
|
||||
when :status_reference
|
||||
serializer = REST::StatusReferenceSerializer
|
||||
when :encrypted_message
|
||||
serializer = REST::EncryptedMessageSerializer
|
||||
else
|
||||
|
|
|
@ -38,6 +38,9 @@ class UserSettingsDecorator
|
|||
user.settings['unsubscribe_modal'] = unsubscribe_modal_preference if change?('setting_unsubscribe_modal')
|
||||
user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal')
|
||||
user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal')
|
||||
user.settings['post_reference_modal'] = post_reference_modal_preference if change?('setting_post_reference_modal')
|
||||
user.settings['add_reference_modal'] = add_reference_modal_preference if change?('setting_add_reference_modal')
|
||||
user.settings['unselect_reference_modal'] = unselect_reference_modal_preference if change?('setting_unselect_reference_modal')
|
||||
user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif')
|
||||
user.settings['display_media'] = display_media_preference if change?('setting_display_media')
|
||||
user.settings['expand_spoilers'] = expand_spoilers_preference if change?('setting_expand_spoilers')
|
||||
|
@ -73,7 +76,9 @@ class UserSettingsDecorator
|
|||
user.settings['disable_joke_appearance'] = disable_joke_appearance_preference if change?('setting_disable_joke_appearance')
|
||||
user.settings['new_features_policy'] = new_features_policy if change?('setting_new_features_policy')
|
||||
user.settings['theme_instance_ticker'] = theme_instance_ticker if change?('setting_theme_instance_ticker')
|
||||
end
|
||||
user.settings['enable_status_reference'] = enable_status_reference_preference if change?('setting_enable_status_reference')
|
||||
user.settings['match_visibility_of_references'] = match_visibility_of_references_preference if change?('setting_match_visibility_of_references')
|
||||
end
|
||||
|
||||
def merged_notification_emails
|
||||
user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
|
||||
|
@ -107,6 +112,18 @@ class UserSettingsDecorator
|
|||
boolean_cast_setting 'setting_delete_modal'
|
||||
end
|
||||
|
||||
def post_reference_modal_preference
|
||||
boolean_cast_setting 'setting_post_reference_modal'
|
||||
end
|
||||
|
||||
def add_reference_modal_preference
|
||||
boolean_cast_setting 'setting_add_reference_modal'
|
||||
end
|
||||
|
||||
def unselect_reference_modal_preference
|
||||
boolean_cast_setting 'setting_unselect_reference_modal'
|
||||
end
|
||||
|
||||
def system_font_ui_preference
|
||||
boolean_cast_setting 'setting_system_font_ui'
|
||||
end
|
||||
|
@ -251,6 +268,14 @@ class UserSettingsDecorator
|
|||
settings['setting_theme_instance_ticker']
|
||||
end
|
||||
|
||||
def enable_status_reference_preference
|
||||
boolean_cast_setting 'setting_enable_status_reference'
|
||||
end
|
||||
|
||||
def match_visibility_of_references_preference
|
||||
boolean_cast_setting 'setting_match_visibility_of_references'
|
||||
end
|
||||
|
||||
def boolean_cast_setting(key)
|
||||
ActiveModel::Type::Boolean.new.cast(settings[key])
|
||||
end
|
||||
|
|
|
@ -80,6 +80,19 @@ class NotificationMailer < ApplicationMailer
|
|||
end
|
||||
end
|
||||
|
||||
def status_reference(recipient, notification)
|
||||
@me = recipient
|
||||
@account = notification.from_account
|
||||
@status = notification.target_status
|
||||
|
||||
return unless @me.user.functional? && @status.present?
|
||||
|
||||
locale_for_account(@me) do
|
||||
thread_by_conversation(@status.conversation)
|
||||
mail to: @me.user.email, subject: I18n.t('notification_mailer.status_reference.subject', name: @account.acct)
|
||||
end
|
||||
end
|
||||
|
||||
def digest(recipient, **opts)
|
||||
return unless recipient.user.functional?
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ module AccountSettings
|
|||
end
|
||||
|
||||
def noindex?
|
||||
true & (local? ? user&.noindex? : settings['noindex'])
|
||||
true & (local? ? user&.noindex? : (settings['noindex'].nil? ? true : settings['noindex']))
|
||||
end
|
||||
|
||||
def hide_network?
|
||||
|
|
|
@ -4,27 +4,51 @@ module StatusThreadingConcern
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
def ancestors(limit, account = nil)
|
||||
find_statuses_from_tree_path(ancestor_ids(limit), account)
|
||||
find_statuses_from_tree_path(ancestor_ids(limit, account), account)
|
||||
end
|
||||
|
||||
def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
|
||||
find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account, promote: true)
|
||||
end
|
||||
|
||||
def thread_references(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
|
||||
find_statuses_from_tree_path(references_ids(limit, account, max_child_id, since_child_id, depth), account)
|
||||
end
|
||||
|
||||
def self_replies(limit)
|
||||
account.statuses.where(in_reply_to_id: id, visibility: [:public, :unlisted]).reorder(id: :asc).limit(limit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ancestor_ids(limit)
|
||||
def ancestor_ids(limit, account)
|
||||
ancestor_ids_account_ids(limit, account).map(&:first).reverse!
|
||||
end
|
||||
|
||||
def descendant_ids(limit, max_child_id, since_child_id, depth)
|
||||
descendant_ids_account_ids(limit, max_child_id, since_child_id, depth).map(&:first)
|
||||
end
|
||||
|
||||
def references_ids(limit, account, max_child_id, since_child_id, depth)
|
||||
ancestors = ancestor_ids_account_ids(limit, account)
|
||||
descendants = descendant_ids_account_ids(limit, max_child_id, since_child_id, depth)
|
||||
self_reply_ids = []
|
||||
self_reply_ids += ancestors .take_while { |id, status_account_id| status_account_id == account_id }.map(&:first)
|
||||
self_reply_ids += descendants.take_while { |id, status_account_id| status_account_id == account_id }.map(&:first)
|
||||
reference_ids = StatusReference.where(status_id: [id] + self_reply_ids).pluck(:target_status_id)
|
||||
reference_ids -= ancestors.map(&:first) + descendants.map(&:first)
|
||||
|
||||
reference_ids.sort!.reverse!
|
||||
end
|
||||
|
||||
def ancestor_ids_account_ids(limit, account)
|
||||
key = "ancestors:#{id}"
|
||||
ancestors = Rails.cache.fetch(key)
|
||||
|
||||
if ancestors.nil? || ancestors[:limit] < limit
|
||||
ids = ancestor_statuses(limit).pluck(:id).reverse!
|
||||
Rails.cache.write key, limit: limit, ids: ids
|
||||
ids
|
||||
ancestor_statuses(limit).pluck(:id, :account_id).tap do |ids_account_ids|
|
||||
Rails.cache.write key, limit: limit, ids: ids_account_ids
|
||||
end
|
||||
else
|
||||
ancestors[:ids].last(limit)
|
||||
end
|
||||
|
@ -32,26 +56,26 @@ module StatusThreadingConcern
|
|||
|
||||
def ancestor_statuses(limit)
|
||||
Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit])
|
||||
WITH RECURSIVE search_tree(id, in_reply_to_id, path)
|
||||
WITH RECURSIVE search_tree(id, account_id, in_reply_to_id, path)
|
||||
AS (
|
||||
SELECT id, in_reply_to_id, ARRAY[id]
|
||||
SELECT id, account_id, in_reply_to_id, ARRAY[id]
|
||||
FROM statuses
|
||||
WHERE id = :id
|
||||
UNION ALL
|
||||
SELECT statuses.id, statuses.in_reply_to_id, path || statuses.id
|
||||
SELECT statuses.id, statuses.account_id, statuses.in_reply_to_id, path || statuses.id
|
||||
FROM search_tree
|
||||
JOIN statuses ON statuses.id = search_tree.in_reply_to_id
|
||||
WHERE NOT statuses.id = ANY(path)
|
||||
)
|
||||
SELECT id
|
||||
SELECT id, account_id
|
||||
FROM search_tree
|
||||
ORDER BY path
|
||||
LIMIT :limit
|
||||
SQL
|
||||
end
|
||||
|
||||
def descendant_ids(limit, max_child_id, since_child_id, depth)
|
||||
descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id)
|
||||
def descendant_ids_account_ids(limit, max_child_id, since_child_id, depth)
|
||||
@descendant_statuses ||= descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id, :account_id)
|
||||
end
|
||||
|
||||
def descendant_statuses(limit, max_child_id, since_child_id, depth)
|
||||
|
@ -60,18 +84,18 @@ module StatusThreadingConcern
|
|||
limit += 1 if limit.present?
|
||||
|
||||
descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
|
||||
WITH RECURSIVE search_tree(id, path)
|
||||
WITH RECURSIVE search_tree(id, account_id, path)
|
||||
AS (
|
||||
SELECT id, ARRAY[id]
|
||||
SELECT id, account_id, ARRAY[id]
|
||||
FROM statuses
|
||||
WHERE id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
|
||||
UNION ALL
|
||||
SELECT statuses.id, path || statuses.id
|
||||
SELECT statuses.id, statuses.account_id, path || statuses.id
|
||||
FROM search_tree
|
||||
JOIN statuses ON statuses.in_reply_to_id = search_tree.id
|
||||
WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
|
||||
)
|
||||
SELECT id
|
||||
SELECT id, account_id
|
||||
FROM search_tree
|
||||
ORDER BY path
|
||||
LIMIT :limit
|
||||
|
@ -81,12 +105,7 @@ module StatusThreadingConcern
|
|||
end
|
||||
|
||||
def find_statuses_from_tree_path(ids, account, promote: false)
|
||||
statuses = Status.with_accounts(ids).to_a
|
||||
account_ids = statuses.map(&:account_id).uniq
|
||||
account_relations = relations_map_for_account(account, account_ids)
|
||||
status_relations = relations_map_for_status(account, statuses)
|
||||
|
||||
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
|
||||
statuses = Status.permitted_statuses_from_ids(ids, account)
|
||||
|
||||
# Order ancestors/descendants by tree path
|
||||
statuses.sort_by! { |status| ids.index(status.id) }
|
||||
|
@ -113,32 +132,4 @@ module StatusThreadingConcern
|
|||
|
||||
arr
|
||||
end
|
||||
|
||||
def relations_map_for_account(account, account_ids)
|
||||
return {} if account.nil?
|
||||
|
||||
presenter = AccountRelationshipsPresenter.new(account_ids, account)
|
||||
{
|
||||
blocking: presenter.blocking,
|
||||
blocked_by: presenter.blocked_by,
|
||||
muting: presenter.muting,
|
||||
following: presenter.following,
|
||||
subscribing: presenter.subscribing,
|
||||
domain_blocking_by_domain: presenter.domain_blocking,
|
||||
}
|
||||
end
|
||||
|
||||
def relations_map_for_status(account, statuses)
|
||||
return {} if account.nil?
|
||||
|
||||
presenter = StatusRelationshipsPresenter.new(statuses, account)
|
||||
{
|
||||
reblogs_map: presenter.reblogs_map,
|
||||
favourites_map: presenter.favourites_map,
|
||||
bookmarks_map: presenter.bookmarks_map,
|
||||
emoji_reactions_map: presenter.emoji_reactions_map,
|
||||
mutes_map: presenter.mutes_map,
|
||||
pins_map: presenter.pins_map,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Context < ActiveModelSerializers::Model
|
||||
attributes :ancestors, :descendants
|
||||
attributes :ancestors, :descendants, :references
|
||||
end
|
||||
|
|
|
@ -19,13 +19,14 @@ class Notification < ApplicationRecord
|
|||
include Paginable
|
||||
|
||||
LEGACY_TYPE_CLASS_MAP = {
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'Poll' => :poll,
|
||||
'EmojiReaction' => :emoji_reaction,
|
||||
'Mention' => :mention,
|
||||
'Status' => :reblog,
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'Poll' => :poll,
|
||||
'EmojiReaction' => :emoji_reaction,
|
||||
'StatusReference' => :status_reference,
|
||||
}.freeze
|
||||
|
||||
TYPES = %i(
|
||||
|
@ -37,6 +38,7 @@ class Notification < ApplicationRecord
|
|||
favourite
|
||||
poll
|
||||
emoji_reaction
|
||||
status_reference
|
||||
).freeze
|
||||
|
||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||
|
@ -46,19 +48,21 @@ class Notification < ApplicationRecord
|
|||
favourite: [favourite: :status],
|
||||
poll: [poll: :status],
|
||||
emoji_reaction: [emoji_reaction: :status],
|
||||
status_reference: [status_reference: :status],
|
||||
}.freeze
|
||||
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :from_account, class_name: 'Account', optional: true
|
||||
belongs_to :activity, polymorphic: true, optional: true
|
||||
|
||||
belongs_to :mention, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :mention, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :favourite, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :poll, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
|
||||
belongs_to :status_reference, foreign_key: 'activity_id', optional: true
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
validates :activity_id, uniqueness: { scope: [:account_id, :type] }, if: -> { type.to_sym == :status }
|
||||
|
@ -93,6 +97,8 @@ class Notification < ApplicationRecord
|
|||
poll&.status
|
||||
when :emoji_reaction
|
||||
emoji_reaction&.status
|
||||
when :status_reference
|
||||
status_reference&.status
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -133,6 +139,8 @@ class Notification < ApplicationRecord
|
|||
notification.poll.status = cached_status
|
||||
when :emoji_reaction
|
||||
notification.emoji_reaction.status = cached_status
|
||||
when :status_reference
|
||||
notification.status_reference.status = cached_status
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -151,7 +159,7 @@ class Notification < ApplicationRecord
|
|||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
when 'Mention', 'StatusReference'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
end
|
||||
end
|
||||
|
|
|
@ -79,6 +79,11 @@ class Status < ApplicationRecord
|
|||
has_and_belongs_to_many :tags
|
||||
has_and_belongs_to_many :preview_cards
|
||||
|
||||
has_many :reference_relationships, class_name: 'StatusReference', foreign_key: :status_id, dependent: :destroy
|
||||
has_many :references, through: :reference_relationships, source: :target_status
|
||||
has_many :referred_by_relationships, class_name: 'StatusReference', foreign_key: :target_status_id, dependent: :destroy
|
||||
has_many :referred_by, through: :referred_by_relationships, source: :status
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
has_one :status_stat, inverse_of: :status
|
||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
|
@ -134,6 +139,7 @@ class Status < ApplicationRecord
|
|||
:tags,
|
||||
:preview_cards,
|
||||
:preloadable_poll,
|
||||
references: { account: :account_stat },
|
||||
account: [:account_stat, :user],
|
||||
active_mentions: { account: :account_stat },
|
||||
reblog: [
|
||||
|
@ -145,6 +151,7 @@ class Status < ApplicationRecord
|
|||
:status_stat,
|
||||
:status_expire,
|
||||
:preloadable_poll,
|
||||
references: { account: :account_stat },
|
||||
account: [:account_stat, :user],
|
||||
active_mentions: { account: :account_stat },
|
||||
],
|
||||
|
@ -165,12 +172,14 @@ class Status < ApplicationRecord
|
|||
ids += reblogs.where(account: Account.local).pluck(:account_id)
|
||||
ids += bookmarks.where(account: Account.local).pluck(:account_id)
|
||||
ids += emoji_reactions.where(account: Account.local).pluck(:account_id)
|
||||
ids += referred_by_statuses.where(account: Account.local).pluck(:account_id)
|
||||
else
|
||||
ids += preloaded.mentions[id] || []
|
||||
ids += preloaded.favourites[id] || []
|
||||
ids += preloaded.reblogs[id] || []
|
||||
ids += preloaded.bookmarks[id] || []
|
||||
ids += preloaded.emoji_reactions[id] || []
|
||||
ids += preloaded.status_references[id] || []
|
||||
end
|
||||
|
||||
ids.uniq
|
||||
|
@ -246,6 +255,10 @@ class Status < ApplicationRecord
|
|||
public_visibility? || unlisted_visibility?
|
||||
end
|
||||
|
||||
def public_safety?
|
||||
distributable? && (!with_media? || non_sensitive_with_media?) && !account.silenced? && !account.suspended?
|
||||
end
|
||||
|
||||
def sign?
|
||||
distributable? || limited_visibility?
|
||||
end
|
||||
|
@ -303,6 +316,14 @@ class Status < ApplicationRecord
|
|||
status_stat&.emoji_reactions_count || 0
|
||||
end
|
||||
|
||||
def status_references_count
|
||||
status_stat&.status_references_count || 0
|
||||
end
|
||||
|
||||
def status_referred_by_count
|
||||
status_stat&.status_referred_by_count || 0
|
||||
end
|
||||
|
||||
def grouped_emoji_reactions(account = nil)
|
||||
(Oj.load(status_stat&.emoji_reactions_cache || '', mode: :strict) || []).tap do |emoji_reactions|
|
||||
if account.present?
|
||||
|
@ -326,6 +347,16 @@ class Status < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def referred_by_statuses(account)
|
||||
statuses = referred_by.includes(:account).to_a
|
||||
account_ids = statuses.map(&:account_id).uniq
|
||||
account_relations = Status.relations_map_for_account(account, account_ids)
|
||||
status_relations = Status.relations_map_for_status(account, statuses)
|
||||
|
||||
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
|
||||
statuses.sort!.reverse!
|
||||
end
|
||||
|
||||
def increment_count!(key)
|
||||
update_status_stat!(key => public_send(key) + 1)
|
||||
end
|
||||
|
@ -382,6 +413,34 @@ class Status < ApplicationRecord
|
|||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
end
|
||||
|
||||
def relations_map_for_account(account, account_ids)
|
||||
return {} if account.nil?
|
||||
|
||||
presenter = AccountRelationshipsPresenter.new(account_ids, account)
|
||||
{
|
||||
blocking: presenter.blocking,
|
||||
blocked_by: presenter.blocked_by,
|
||||
muting: presenter.muting,
|
||||
following: presenter.following,
|
||||
subscribing: presenter.subscribing,
|
||||
domain_blocking_by_domain: presenter.domain_blocking,
|
||||
}
|
||||
end
|
||||
|
||||
def relations_map_for_status(account, statuses)
|
||||
return {} if account.nil?
|
||||
|
||||
presenter = StatusRelationshipsPresenter.new(statuses, account)
|
||||
{
|
||||
reblogs_map: presenter.reblogs_map,
|
||||
favourites_map: presenter.favourites_map,
|
||||
bookmarks_map: presenter.bookmarks_map,
|
||||
emoji_reactions_map: presenter.emoji_reactions_map,
|
||||
mutes_map: presenter.mutes_map,
|
||||
pins_map: presenter.pins_map,
|
||||
}
|
||||
end
|
||||
|
||||
def reload_stale_associations!(cached_items)
|
||||
account_ids = []
|
||||
|
||||
|
@ -402,6 +461,16 @@ class Status < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def permitted_statuses_from_ids(ids, account)
|
||||
statuses = Status.with_accounts(ids).to_a
|
||||
account_ids = statuses.map(&:account_id).uniq
|
||||
account_relations = relations_map_for_account(account, account_ids)
|
||||
status_relations = relations_map_for_status(account, statuses)
|
||||
|
||||
statuses.reject! { |status| StatusFilter.new(status, account, account_relations, status_relations).filtered? }
|
||||
statuses
|
||||
end
|
||||
|
||||
def permitted_for(target_account, account)
|
||||
visibility = [:public, :unlisted]
|
||||
|
||||
|
|
40
app/models/status_reference.rb
Normal file
40
app/models/status_reference.rb
Normal 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
|
|
@ -3,15 +3,17 @@
|
|||
#
|
||||
# Table name: status_stats
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8) not null
|
||||
# replies_count :bigint(8) default(0), not null
|
||||
# reblogs_count :bigint(8) default(0), not null
|
||||
# favourites_count :bigint(8) default(0), not null
|
||||
# emoji_reactions_count :bigint(8) default(0), not null
|
||||
# emoji_reactions_cache :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint(8) not null, primary key
|
||||
# status_id :bigint(8) not null
|
||||
# replies_count :bigint(8) default(0), not null
|
||||
# reblogs_count :bigint(8) default(0), not null
|
||||
# favourites_count :bigint(8) default(0), not null
|
||||
# emoji_reactions_count :bigint(8) default(0), not null
|
||||
# emoji_reactions_cache :string default(""), not null
|
||||
# status_references_count :bigint(8) default(0), not null
|
||||
# status_referred_by_count :bigint(8) default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class StatusStat < ApplicationRecord
|
||||
|
|
|
@ -134,6 +134,8 @@ class User < ApplicationRecord
|
|||
:hide_statuses_count, :hide_following_count, :hide_followers_count, :disable_joke_appearance,
|
||||
:new_features_policy,
|
||||
:theme_instance_ticker,
|
||||
:enable_status_reference, :match_visibility_of_references,
|
||||
:post_reference_modal, :add_reference_modal, :unselect_reference_modal,
|
||||
|
||||
to: :settings, prefix: :setting, allow_nil: false
|
||||
|
||||
|
|
|
@ -53,6 +53,12 @@ class StatusPolicy < ApplicationPolicy
|
|||
limited? && owned? && (!reply? || record.thread.conversation_id != record.conversation_id)
|
||||
end
|
||||
|
||||
def subscribe?
|
||||
return false unless show?
|
||||
|
||||
!unlisted? || owned? || following_author? || mention_exists?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def requires_mention?
|
||||
|
@ -63,6 +69,10 @@ class StatusPolicy < ApplicationPolicy
|
|||
author.id == current_account&.id
|
||||
end
|
||||
|
||||
def unlisted?
|
||||
record.unlisted_visibility?
|
||||
end
|
||||
|
||||
def private?
|
||||
record.private_visibility?
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry
|
||||
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quote_uri, :expiry, :references
|
||||
|
||||
attributes :id, :type, :summary,
|
||||
:in_reply_to, :published, :url,
|
||||
|
@ -21,6 +21,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
has_many :virtual_tags, key: :tag
|
||||
|
||||
has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local?
|
||||
has_one :references, serializer: ActivityPub::CollectionSerializer, if: :local?
|
||||
|
||||
has_many :poll_options, key: :one_of, if: :poll_and_not_multiple?
|
||||
has_many :poll_options, key: :any_of, if: :poll_and_multiple?
|
||||
|
@ -66,6 +67,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
)
|
||||
end
|
||||
|
||||
INLINE_REFERENCE_MAX = 5
|
||||
|
||||
def references
|
||||
@references = Status.where(id: object.reference_relationships.order(target_status_id: :asc).limit(INLINE_REFERENCE_MAX).pluck(:target_status_id)).reorder(id: :asc)
|
||||
last_id = @references&.last&.id if @references.size == INLINE_REFERENCE_MAX
|
||||
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
type: :unordered,
|
||||
id: ActivityPub::TagManager.instance.references_uri_for(object),
|
||||
first: ActivityPub::CollectionPresenter.new(
|
||||
type: :unordered,
|
||||
part_of: ActivityPub::TagManager.instance.references_uri_for(object),
|
||||
items: @references.map(&:uri),
|
||||
next: last_id ? ActivityPub::TagManager.instance.references_uri_for(object, page: true, min_id: last_id) : nil,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
def language?
|
||||
object.language.present?
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
class InitialStateSerializer < ActiveModel::Serializer
|
||||
attributes :meta, :compose, :accounts, :lists,
|
||||
:media_attachments, :settings
|
||||
:media_attachments, :status_references, :settings
|
||||
|
||||
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||
|
||||
|
@ -58,6 +58,12 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
store[:disable_joke_appearance] = object.current_account.user.setting_disable_joke_appearance
|
||||
store[:new_features_policy] = object.current_account.user.setting_new_features_policy
|
||||
store[:theme_instance_ticker] = object.current_account.user.setting_theme_instance_ticker
|
||||
store[:enable_status_reference] = object.current_account.user.setting_enable_status_reference
|
||||
store[:match_visibility_of_references] = object.current_account.user.setting_match_visibility_of_references
|
||||
store[:post_reference_modal] = object.current_account.user.setting_post_reference_modal
|
||||
store[:add_reference_modal] = object.current_account.user.setting_add_reference_modal
|
||||
store[:unselect_reference_modal] = object.current_account.user.setting_unselect_reference_modal
|
||||
|
||||
else
|
||||
store[:auto_play_gif] = Setting.auto_play_gif
|
||||
store[:display_media] = Setting.display_media
|
||||
|
@ -100,6 +106,10 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
{ accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
|
||||
end
|
||||
|
||||
def status_references
|
||||
{ max_references: StatusReferenceValidator::LIMIT }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instance_presenter
|
||||
|
|
|
@ -3,4 +3,5 @@
|
|||
class REST::ContextSerializer < ActiveModel::Serializer
|
||||
has_many :ancestors, serializer: REST::StatusSerializer
|
||||
has_many :descendants, serializer: REST::StatusSerializer
|
||||
has_many :references, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
|
|
@ -85,6 +85,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
emoji_reactions: {
|
||||
max_reactions: EmojiReactionValidator::LIMIT,
|
||||
},
|
||||
|
||||
status_references: {
|
||||
max_references: StatusReferenceValidator::LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -129,6 +133,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
:emoji_reaction,
|
||||
:misskey_birthday,
|
||||
:misskey_location,
|
||||
:status_reference,
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def status_type?
|
||||
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction].include?(object.type)
|
||||
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction, :status_reference].include?(object.type)
|
||||
end
|
||||
|
||||
def reblog?
|
||||
|
|
|
@ -8,6 +8,29 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
|
|||
:provider_url, :html, :width, :height,
|
||||
:image, :embed_url, :blurhash
|
||||
|
||||
attribute :status_id, if: :status_id
|
||||
attribute :account_id, if: :account_id
|
||||
|
||||
attr_reader :status, :account
|
||||
|
||||
def initialize(object, options = {})
|
||||
super
|
||||
|
||||
return if object.nil?
|
||||
|
||||
@status = EntityCache.instance.holding_status(object.url.delete_suffix('/references'))
|
||||
@account = @status&.account
|
||||
@account = EntityCache.instance.holding_account(object.url) if @status.nil?
|
||||
end
|
||||
|
||||
def status_id
|
||||
status.id.to_s if status.present?
|
||||
end
|
||||
|
||||
def account_id
|
||||
account.id.to_s if account.present?
|
||||
end
|
||||
|
||||
def image
|
||||
object.image? ? full_asset_url(object.image.url(:original)) : nil
|
||||
end
|
||||
|
|
|
@ -4,7 +4,9 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
|
||||
:sensitive, :spoiler_text, :visibility, :language,
|
||||
:uri, :url, :replies_count, :reblogs_count,
|
||||
:favourites_count, :emoji_reactions_count, :emoji_reactions
|
||||
:favourites_count, :emoji_reactions_count, :emoji_reactions,
|
||||
:status_reference_ids,
|
||||
:status_references_count, :status_referred_by_count
|
||||
|
||||
attribute :favourited, if: :current_user?
|
||||
attribute :reblogged, if: :current_user?
|
||||
|
@ -144,6 +146,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
object.grouped_emoji_reactions(current_user&.account)
|
||||
end
|
||||
|
||||
def status_reference_ids
|
||||
object.references.map(&:id).map(&:to_s)
|
||||
end
|
||||
|
||||
def reblogged
|
||||
if instance_options && instance_options[:relationships]
|
||||
instance_options[:relationships].reblogs_map[object.id] || false
|
||||
|
|
48
app/services/activitypub/fetch_references_service.rb
Normal file
48
app/services/activitypub/fetch_references_service.rb
Normal 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
|
|
@ -65,6 +65,7 @@ class FetchLinkCardService < BaseService
|
|||
def parse_urls
|
||||
if @status.local?
|
||||
urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
|
||||
urls.push(Addressable::URI.parse(references_short_account_status_url(@status.account, @status))) if @status.references.exists?
|
||||
else
|
||||
html = Nokogiri::HTML(@status.text)
|
||||
links = html.css(':not(.quote-inline) > a')
|
||||
|
@ -76,7 +77,12 @@ class FetchLinkCardService < BaseService
|
|||
|
||||
def bad_url?(uri)
|
||||
# Avoid local instance URLs and invalid URLs
|
||||
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
|
||||
uri.host.blank? || (TagManager.instance.local_url?(uri.to_s) && !status_reference_url?(uri.to_s)) || !%w(http https).include?(uri.scheme)
|
||||
end
|
||||
|
||||
def status_reference_url?(uri)
|
||||
recognized_params = Rails.application.routes.recognize_path(uri)
|
||||
recognized_params && recognized_params[:controller] == 'statuses' && recognized_params[:action] == 'references'
|
||||
end
|
||||
|
||||
# rubocop:disable Naming/MethodParameterName
|
||||
|
|
|
@ -50,6 +50,10 @@ class NotifyService < BaseService
|
|||
false
|
||||
end
|
||||
|
||||
def blocked_status_reference?
|
||||
FeedManager.instance.filter?(:status_references, @notification.status_reference.status, @recipient)
|
||||
end
|
||||
|
||||
def following_sender?
|
||||
return @following_sender if defined?(@following_sender)
|
||||
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
|
||||
|
|
|
@ -103,6 +103,7 @@ class PostStatusService < BaseService
|
|||
|
||||
ProcessHashtagsService.new.call(@status)
|
||||
ProcessMentionsService.new.call(@status, @circle)
|
||||
ProcessStatusReferenceService.new.call(@status, status_reference_ids: (@options[:status_reference_ids] || []) + [@quote_id], urls: @options[:status_reference_urls])
|
||||
end
|
||||
|
||||
def schedule_status!
|
||||
|
|
87
app/services/process_status_reference_service.rb
Normal file
87
app/services/process_status_reference_service.rb
Normal 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
|
||||
|
10
app/validators/status_reference_validator.rb
Normal file
10
app/validators/status_reference_validator.rb
Normal 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
|
45
app/views/notification_mailer/status_reference.html.haml
Normal file
45
app/views/notification_mailer/status_reference.html.haml
Normal 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'
|
5
app/views/notification_mailer/status_reference.text.erb
Normal file
5
app/views/notification_mailer/status_reference.text.erb
Normal 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 %>
|
|
@ -61,6 +61,9 @@
|
|||
= f.input :setting_unsubscribe_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
= f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
|
||||
= f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
|
||||
= f.input :setting_post_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
= f.input :setting_add_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
= f.input :setting_unselect_reference_modal, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
|
||||
%h4= t 'appearance.sensitive_content'
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
= ff.input :favourite, as: :boolean, wrapper: :with_label
|
||||
= ff.input :emoji_reaction, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
= ff.input :mention, as: :boolean, wrapper: :with_label
|
||||
= ff.input :status_reference, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
|
||||
- if current_user.staff?
|
||||
= ff.input :report, as: :boolean, wrapper: :with_label
|
||||
|
|
|
@ -82,6 +82,12 @@
|
|||
.fields-group
|
||||
= f.input :setting_show_reply_tree_button, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
|
||||
.fields-group
|
||||
= f.input :setting_enable_status_reference, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
|
||||
.fields-group
|
||||
= f.input :setting_match_visibility_of_references, as: :boolean, wrapper: :with_label, fedibird_features: true
|
||||
|
||||
-# .fields-group
|
||||
-# = f.input :setting_show_target, as: :boolean, wrapper: :with_label
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
- description = status_description(activity)
|
||||
- description = status_description(activity) unless description
|
||||
|
||||
%meta{ name: 'description', content: description }/
|
||||
= opengraph 'og:description', description
|
||||
|
|
28
app/views/statuses/_reference_status.html.haml
Normal file
28
app/views/statuses/_reference_status.html.haml
Normal 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
Loading…
Reference in a new issue