Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `.github/workflows/build-image.yml`: Fix erroneous deletion in a previous merge. - `Gemfile`: Conflict caused by glitch-soc-only hCaptcha dependency - `app/controllers/auth/sessions_controller.rb`: Minor conflict due to glitch-soc's theming system. - `app/controllers/filters_controller.rb`: Minor conflict due to glitch-soc's theming system. - `app/serializers/rest/status_serializer.rb`: Minor conflict due to glitch-soc having an extra `local_only` propertyqrt
commit
fe5f6bc7ed
|
@ -133,6 +133,12 @@ jobs:
|
|||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20180707154237
|
||||
name: Run migrations up to v2.4.3
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4_3
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all remaining migrations
|
||||
|
@ -167,14 +173,22 @@ jobs:
|
|||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate VERSION=20180707154237
|
||||
name: Run migrations up to v2.4.3
|
||||
environment:
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:populate_v2_4_3
|
||||
name: Populate database with test data
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all pre-deployment migrations
|
||||
name: Run all remaining pre-deployment migrations
|
||||
environment:
|
||||
SKIP_POST_DEPLOYMENT_MIGRATIONS: true
|
||||
- run:
|
||||
command: ./bin/rails db:migrate
|
||||
name: Run all post-deployment remaining migrations
|
||||
name: Run all post-deployment migrations
|
||||
- run:
|
||||
command: ./bin/rails tests:migrations:check_database
|
||||
name: Check migration result
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
name: Build container image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-image.yml
|
||||
- Dockerfile
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
- uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: github.event_name != 'pull_request'
|
||||
- uses: docker/metadata-action@v4
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mastodon
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
type=match,pattern=v(.*),group=0
|
||||
type=ref,event=pr
|
||||
- uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:latest
|
||||
cache-to: type=inline
|
|
@ -5,7 +5,7 @@ SHELL ["/bin/bash", "-c"]
|
|||
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
|
||||
|
||||
# Install Node v16 (LTS)
|
||||
ENV NODE_VER="16.14.2"
|
||||
ENV NODE_VER="16.15.1"
|
||||
RUN ARCH= && \
|
||||
dpkgArch="$(dpkg --print-architecture)" && \
|
||||
case "${dpkgArch##*-}" in \
|
||||
|
|
5
Gemfile
5
Gemfile
|
@ -13,7 +13,7 @@ gem 'thor', '~> 1.2'
|
|||
gem 'rack', '~> 2.2.3'
|
||||
|
||||
gem 'hamlit-rails', '~> 0.2'
|
||||
gem 'pg', '~> 1.3'
|
||||
gem 'pg', '~> 1.4'
|
||||
gem 'makara', '~> 0.5'
|
||||
gem 'pghero', '~> 2.8'
|
||||
gem 'dotenv-rails', '~> 2.7'
|
||||
|
@ -53,7 +53,7 @@ gem 'fastimage'
|
|||
gem 'hiredis', '~> 0.6'
|
||||
gem 'redis-namespace', '~> 1.8'
|
||||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.0'
|
||||
gem 'http', '~> 5.1'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.5.0'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
|
@ -157,3 +157,4 @@ gem 'connection_pool', require: false
|
|||
gem 'xorcist', '~> 1.1'
|
||||
|
||||
gem 'hcaptcha', '~> 7.1'
|
||||
gem 'cocoon', '~> 1.2'
|
||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -163,6 +163,7 @@ GEM
|
|||
elasticsearch-dsl
|
||||
chunky_png (1.4.0)
|
||||
climate_control (0.2.0)
|
||||
cocoon (1.2.15)
|
||||
coderay (1.1.3)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.1.10)
|
||||
|
@ -293,12 +294,12 @@ GEM
|
|||
hkdf (0.3.0)
|
||||
html_tokenizer (0.0.7)
|
||||
htmlentities (4.3.4)
|
||||
http (5.0.4)
|
||||
http (5.1.0)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.4.0)
|
||||
http-cookie (1.0.4)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
http_accept_language (2.1.1)
|
||||
|
@ -448,7 +449,7 @@ GEM
|
|||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.3.5)
|
||||
pg (1.4.0)
|
||||
pghero (2.8.3)
|
||||
activerecord (>= 5)
|
||||
pkg-config (1.4.7)
|
||||
|
@ -679,7 +680,7 @@ GEM
|
|||
tzinfo (>= 1.0.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8)
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.1.0)
|
||||
uniform_notifier (1.16.0)
|
||||
validate_email (0.1.6)
|
||||
|
@ -749,6 +750,7 @@ DEPENDENCIES
|
|||
charlock_holmes (~> 0.7.7)
|
||||
chewy (~> 7.2)
|
||||
climate_control (~> 0.2)
|
||||
cocoon (~> 1.2)
|
||||
color_diff (~> 0.1)
|
||||
concurrent-ruby
|
||||
connection_pool
|
||||
|
@ -771,7 +773,7 @@ DEPENDENCIES
|
|||
hcaptcha (~> 7.1)
|
||||
hiredis (~> 0.6)
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.0)
|
||||
http (~> 5.1)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.5.0)
|
||||
i18n-tasks (~> 1.0)
|
||||
|
@ -799,7 +801,7 @@ DEPENDENCIES
|
|||
omniauth-saml (~> 1.10)
|
||||
ox (~> 2.14)
|
||||
parslet
|
||||
pg (~> 1.3)
|
||||
pg (~> 1.4)
|
||||
pghero (~> 2.8)
|
||||
pkg-config (~> 1.4)
|
||||
posix-spawn
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Admin::DomainAllowsController < Api::BaseController
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show]
|
||||
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show]
|
||||
before_action :require_staff!
|
||||
before_action :set_domain_allows, only: :index
|
||||
before_action :set_domain_allow, only: [:show, :destroy]
|
||||
|
||||
after_action :insert_pagination_headers, only: :index
|
||||
|
||||
PAGINATION_PARAMS = %i(limit).freeze
|
||||
|
||||
def create
|
||||
authorize :domain_allow, :create?
|
||||
|
||||
@domain_allow = DomainAllow.find_by(resource_params)
|
||||
|
||||
if @domain_allow.nil?
|
||||
@domain_allow = DomainAllow.create!(resource_params)
|
||||
log_action :create, @domain_allow
|
||||
end
|
||||
|
||||
render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer
|
||||
end
|
||||
|
||||
def index
|
||||
authorize :domain_allow, :index?
|
||||
render json: @domain_allows, each_serializer: REST::Admin::DomainAllowSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @domain_allow, :show?
|
||||
render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @domain_allow, :destroy?
|
||||
UnallowDomainService.new.call(@domain_allow)
|
||||
log_action :destroy, @domain_allow
|
||||
render json: @domain_allow, serializer: REST::Admin::DomainAllowSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_domain_allows
|
||||
@domain_allows = filtered_domain_allows.order(id: :desc).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||
end
|
||||
|
||||
def set_domain_allow
|
||||
@domain_allow = DomainAllow.find(params[:id])
|
||||
end
|
||||
|
||||
def filtered_domain_allows
|
||||
# TODO: no filtering yet
|
||||
DomainAllow.all
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@domain_allows.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@domain_allows.first.id
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@domain_allows.size == limit_param(LIMIT)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:domain)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Filters::KeywordsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
|
||||
before_action :require_user!
|
||||
|
||||
before_action :set_keywords, only: :index
|
||||
before_action :set_keyword, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @keywords, each_serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
@keyword = current_account.custom_filters.find(params[:filter_id]).keywords.create!(resource_params)
|
||||
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@keyword.update!(resource_params)
|
||||
|
||||
render json: @keyword, serializer: REST::FilterKeywordSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
@keyword.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_keywords
|
||||
filter = current_account.custom_filters.includes(:keywords).find(params[:filter_id])
|
||||
@keywords = filter.keywords
|
||||
end
|
||||
|
||||
def set_keyword
|
||||
@keyword = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:keyword, :whole_word)
|
||||
end
|
||||
end
|
|
@ -8,21 +8,32 @@ class Api::V1::FiltersController < Api::BaseController
|
|||
before_action :set_filter, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @filters, each_serializer: REST::FilterSerializer
|
||||
render json: @filters, each_serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
@filter = current_account.custom_filters.create!(resource_params)
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
ApplicationRecord.transaction do
|
||||
filter_category = current_account.custom_filters.create!(resource_params)
|
||||
@filter = filter_category.keywords.create!(keyword_params)
|
||||
end
|
||||
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@filter.update!(resource_params)
|
||||
render json: @filter, serializer: REST::FilterSerializer
|
||||
ApplicationRecord.transaction do
|
||||
@filter.update!(keyword_params)
|
||||
@filter.custom_filter.assign_attributes(filter_params)
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||
|
||||
@filter.custom_filter.save!
|
||||
end
|
||||
|
||||
render json: @filter, serializer: REST::V1::FilterSerializer
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
@ -33,14 +44,22 @@ class Api::V1::FiltersController < Api::BaseController
|
|||
private
|
||||
|
||||
def set_filters
|
||||
@filters = current_account.custom_filters
|
||||
@filters = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account })
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = current_account.custom_filters.find(params[:id])
|
||||
@filter = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account: current_account }).find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
|
||||
end
|
||||
|
||||
def filter_params
|
||||
resource_params.slice(:expires_in, :irreversible, :context)
|
||||
end
|
||||
|
||||
def keyword_params
|
||||
resource_params.slice(:phrase, :whole_word)
|
||||
end
|
||||
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])
|
||||
params.require(:data).permit(:policy, alerts: Notification::TYPES)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::FiltersController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
|
||||
before_action :require_user!
|
||||
before_action :set_filters, only: :index
|
||||
before_action :set_filter, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
render json: @filters, each_serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def create
|
||||
@filter = current_account.custom_filters.create!(resource_params)
|
||||
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def update
|
||||
@filter.update!(resource_params)
|
||||
|
||||
render json: @filter, serializer: REST::FilterSerializer, rules_requested: true
|
||||
end
|
||||
|
||||
def destroy
|
||||
@filter.destroy!
|
||||
render_empty
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_filters
|
||||
@filters = current_account.custom_filters.includes(:keywords)
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = current_account.custom_filters.find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
|
||||
end
|
||||
end
|
|
@ -8,12 +8,18 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
skip_before_action :update_user_sign_in
|
||||
|
||||
prepend_before_action :set_pack
|
||||
prepend_before_action :check_suspicious!, only: [:create]
|
||||
|
||||
include TwoFactorAuthenticationConcern
|
||||
|
||||
before_action :set_instance_presenter, only: [:new]
|
||||
before_action :set_body_classes
|
||||
|
||||
def check_suspicious!
|
||||
user = find_user
|
||||
@login_is_suspicious = suspicious_sign_in?(user) unless user.nil?
|
||||
end
|
||||
|
||||
def create
|
||||
super do |resource|
|
||||
# We only need to call this if this hasn't already been
|
||||
|
@ -148,7 +154,7 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if suspicious_sign_in?(user)
|
||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||
end
|
||||
|
||||
def suspicious_sign_in?(user)
|
||||
|
|
|
@ -4,17 +4,17 @@ class FiltersController < ApplicationController
|
|||
layout 'admin'
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_filters, only: :index
|
||||
before_action :set_filter, only: [:edit, :update, :destroy]
|
||||
before_action :set_pack
|
||||
before_action :set_body_classes
|
||||
|
||||
def index
|
||||
@filters = current_account.custom_filters.order(:phrase)
|
||||
@filters = current_account.custom_filters.includes(:keywords).order(:phrase)
|
||||
end
|
||||
|
||||
def new
|
||||
@filter = current_account.custom_filters.build
|
||||
@filter = current_account.custom_filters.build(action: :warn)
|
||||
@filter.keywords.build
|
||||
end
|
||||
|
||||
def create
|
||||
|
@ -48,16 +48,12 @@ class FiltersController < ApplicationController
|
|||
use_pack 'settings'
|
||||
end
|
||||
|
||||
def set_filters
|
||||
@filters = current_account.custom_filters
|
||||
end
|
||||
|
||||
def set_filter
|
||||
@filter = current_account.custom_filters.find(params[:id])
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:custom_filter).permit(:phrase, :expires_in, :irreversible, :whole_word, context: [])
|
||||
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
|
|
|
@ -16,7 +16,11 @@ module RoutingHelper
|
|||
def full_asset_url(source, **options)
|
||||
source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage?
|
||||
|
||||
URI.join(root_url, source).to_s
|
||||
URI.join(asset_host, source).to_s
|
||||
end
|
||||
|
||||
def asset_host
|
||||
Rails.configuration.action_controller.asset_host || root_url
|
||||
end
|
||||
|
||||
def full_pack_url(source, **options)
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import api from '../api';
|
||||
|
||||
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
|
||||
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
|
||||
export const FILTERS_FETCH_FAIL = 'FILTERS_FETCH_FAIL';
|
||||
|
||||
export const fetchFilters = () => (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: FILTERS_FETCH_REQUEST,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
api(getState)
|
||||
.get('/api/v1/filters')
|
||||
.then(({ data }) => dispatch({
|
||||
type: FILTERS_FETCH_SUCCESS,
|
||||
filters: data,
|
||||
skipLoading: true,
|
||||
}))
|
||||
.catch(err => dispatch({
|
||||
type: FILTERS_FETCH_FAIL,
|
||||
err,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
}));
|
||||
};
|
|
@ -5,6 +5,7 @@ export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
|
|||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
export const POLLS_IMPORT = 'POLLS_IMPORT';
|
||||
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
||||
|
||||
function pushUnique(array, object) {
|
||||
if (array.every(element => element.id !== object.id)) {
|
||||
|
@ -28,6 +29,10 @@ export function importStatuses(statuses) {
|
|||
return { type: STATUSES_IMPORT, statuses };
|
||||
}
|
||||
|
||||
export function importFilters(filters) {
|
||||
return { type: FILTERS_IMPORT, filters };
|
||||
}
|
||||
|
||||
export function importPolls(polls) {
|
||||
return { type: POLLS_IMPORT, polls };
|
||||
}
|
||||
|
@ -61,11 +66,16 @@ export function importFetchedStatuses(statuses) {
|
|||
const accounts = [];
|
||||
const normalStatuses = [];
|
||||
const polls = [];
|
||||
const filters = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
|
||||
pushUnique(accounts, status.account);
|
||||
|
||||
if (status.filtered) {
|
||||
status.filtered.forEach(result => pushUnique(filters, result.filter));
|
||||
}
|
||||
|
||||
if (status.reblog && status.reblog.id) {
|
||||
processStatus(status.reblog);
|
||||
}
|
||||
|
@ -80,6 +90,7 @@ export function importFetchedStatuses(statuses) {
|
|||
dispatch(importPolls(polls));
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
dispatch(importFilters(filters));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,14 @@ export function normalizeAccount(account) {
|
|||
return account;
|
||||
}
|
||||
|
||||
export function normalizeFilterResult(result) {
|
||||
const normalResult = { ...result };
|
||||
|
||||
normalResult.filter = normalResult.filter.id;
|
||||
|
||||
return normalResult;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus) {
|
||||
const normalStatus = { ...status };
|
||||
normalStatus.account = status.account.id;
|
||||
|
@ -54,6 +62,10 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.poll = status.poll.id;
|
||||
}
|
||||
|
||||
if (status.filtered) {
|
||||
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
||||
}
|
||||
|
||||
// Only calculate these values when status first encountered and
|
||||
// when the underlying values change. Otherwise keep the ones
|
||||
// already in the reducer
|
||||
|
|
|
@ -12,10 +12,8 @@ import { saveSettings } from './settings';
|
|||
import { defineMessages } from 'react-intl';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { getFiltersRegex } from '../selectors';
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
import compareId from 'mastodon/compare_id';
|
||||
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
||||
import { requestNotificationPermission } from '../utils/notifications';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
|
@ -62,20 +60,17 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
|
||||
|
||||
let filtered = false;
|
||||
|
||||
if (['mention', 'status'].includes(notification.type)) {
|
||||
const dropRegex = filters[0];
|
||||
const regex = filters[1];
|
||||
const searchIndex = searchTextFromRawStatus(notification.status);
|
||||
if (['mention', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||
|
||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered = regex && regex.test(searchIndex);
|
||||
filtered = filters.length > 0;
|
||||
}
|
||||
|
||||
if (['follow_request'].includes(notification.type)) {
|
||||
|
@ -91,6 +86,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
dispatch(importFetchedStatus(notification.status));
|
||||
}
|
||||
|
||||
if (notification.report) {
|
||||
dispatch(importFetchedAccount(notification.report.target_account));
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE,
|
||||
notification,
|
||||
|
@ -134,6 +133,7 @@ const excludeTypesFromFilter = filter => {
|
|||
'status',
|
||||
'update',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
]);
|
||||
|
||||
return allTypes.filterNot(item => item === filter).toJS();
|
||||
|
@ -179,6 +179,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
|||
|
||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
||||
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
|
|
|
@ -21,7 +21,6 @@ import {
|
|||
updateReaction as updateAnnouncementsReaction,
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { fetchFilters } from './filters';
|
||||
import { getLocale } from '../locales';
|
||||
|
||||
const { messages } = getLocale();
|
||||
|
@ -97,9 +96,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||
case 'conversation':
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'filters_changed':
|
||||
dispatch(fetchFilters());
|
||||
break;
|
||||
case 'announcement':
|
||||
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
||||
break;
|
||||
|
|
|
@ -132,8 +132,16 @@ export default class IconButton extends React.PureComponent {
|
|||
);
|
||||
|
||||
if (href) {
|
||||
contents = (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={classes}
|
||||
style={style}
|
||||
>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
|
|
|
@ -116,6 +116,7 @@ class Status extends ImmutablePureComponent {
|
|||
state = {
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
statusId: undefined,
|
||||
forceFilter: undefined,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
|
@ -277,6 +278,15 @@ class Status extends ImmutablePureComponent {
|
|||
this.handleToggleMediaVisibility();
|
||||
}
|
||||
|
||||
handleUnfilterClick = e => {
|
||||
this.setState({ forceFilter: false });
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.setState({ forceFilter: true });
|
||||
}
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
|
@ -328,7 +338,8 @@ class Status extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
|
||||
const matchedFilters = status.get('filtered') || status.getIn(['reblog', 'filtered']);
|
||||
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
|
@ -337,7 +348,11 @@ class Status extends ImmutablePureComponent {
|
|||
return (
|
||||
<HotKeys handlers={minHandlers}>
|
||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
||||
{' '}
|
||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
|
@ -496,7 +511,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
{media}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters && this.handleFilterClick} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
|
@ -38,6 +38,7 @@ const messages = defineMessages({
|
|||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
|
||||
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
|
@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onBookmark: PropTypes.func,
|
||||
onFilter: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
withCounters: PropTypes.bool,
|
||||
scrollKey: PropTypes.string,
|
||||
|
@ -207,6 +209,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
handleFilter = () => {
|
||||
this.props.onFilter();
|
||||
}
|
||||
|
||||
handleCopy = () => {
|
||||
const url = this.props.status.get('url');
|
||||
const textarea = document.createElement('textarea');
|
||||
|
@ -226,6 +232,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.props.onFilter();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
|
||||
|
@ -329,6 +340,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
const filterButton = this.props.onFilter && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<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 />
|
||||
|
@ -337,6 +352,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
{shareButton}
|
||||
|
||||
{filterButton}
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
|
|
|
@ -178,6 +178,19 @@ export default class ColumnSettings extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStaff && (
|
||||
<div role='group' aria-labelledby='notifications-admin-report'>
|
||||
<span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
|
||||
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />}
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
|
||||
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { me } from 'mastodon/initial_state';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import AccountContainer from 'mastodon/containers/account_container';
|
||||
import Report from './report';
|
||||
import FollowRequestContainer from '../containers/follow_request_container';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Permalink from 'mastodon/components/permalink';
|
||||
|
@ -21,6 +22,7 @@ const messages = defineMessages({
|
|||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
});
|
||||
|
||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||
|
@ -367,6 +369,32 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderAdminReport (notification, account, link) {
|
||||
const { intl, unread, report } = this.props;
|
||||
|
||||
const targetAccount = report.get('target_account');
|
||||
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
||||
const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.adminReport, { name: account.get('acct'), target: notification.getIn(['report', 'target_account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__favourite-icon-wrapper'>
|
||||
<Icon id='flag' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { notification } = this.props;
|
||||
const account = notification.get('account');
|
||||
|
@ -392,6 +420,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderPoll(notification, account);
|
||||
case 'admin.sign_up':
|
||||
return this.renderAdminSignUp(notification, account, link);
|
||||
case 'admin.report':
|
||||
return this.renderAdminReport(notification, account, link);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import AvatarOverlay from 'mastodon/components/avatar_overlay';
|
||||
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
|
||||
other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
|
||||
spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
|
||||
violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Report extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
report: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, hidden, report, account } = this.props;
|
||||
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<Fragment>
|
||||
{report.get('id')}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification__report'>
|
||||
<div className='notification__report__avatar'>
|
||||
<AvatarOverlay account={report.get('target_account')} friend={account} />
|
||||
</div>
|
||||
|
||||
<div className='notification__report__details'>
|
||||
<div>
|
||||
<RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} />
|
||||
<br />
|
||||
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
||||
</div>
|
||||
|
||||
<div className='notification__report__actions'>
|
||||
<a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeGetNotification, makeGetStatus } from '../../../selectors';
|
||||
import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors';
|
||||
import Notification from '../components/notification';
|
||||
import { initBoostModal } from '../../../actions/boosts';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
|
@ -18,12 +18,14 @@ import { boostModal } from '../../../initial_state';
|
|||
const makeMapStateToProps = () => {
|
||||
const getNotification = makeGetNotification();
|
||||
const getStatus = makeGetStatus();
|
||||
const getReport = makeGetReport();
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const notification = getNotification(state, props.notification, props.accountId);
|
||||
return {
|
||||
notification: notification,
|
||||
status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null,
|
||||
report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import { debounce } from 'lodash';
|
|||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
|
||||
import { expandHomeTimeline } from '../../actions/timelines';
|
||||
import { expandNotifications } from '../../actions/notifications';
|
||||
import { fetchFilters } from '../../actions/filters';
|
||||
import { fetchRules } from '../../actions/rules';
|
||||
import { clearHeight } from '../../actions/height_cache';
|
||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
|
||||
|
@ -368,7 +367,7 @@ class UI extends React.PureComponent {
|
|||
this.props.dispatch(fetchMarkers());
|
||||
this.props.dispatch(expandHomeTimeline());
|
||||
this.props.dispatch(expandNotifications());
|
||||
setTimeout(() => this.props.dispatch(fetchFilters()), 500);
|
||||
|
||||
setTimeout(() => this.props.dispatch(fetchRules()), 3000);
|
||||
|
||||
this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
|
||||
|
|
|
@ -2532,6 +2532,10 @@
|
|||
{
|
||||
"defaultMessage": "New sign-ups:",
|
||||
"id": "notifications.column_settings.admin.sign_up"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "New reports:",
|
||||
"id": "notifications.column_settings.admin.report"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/notifications/components/column_settings.json"
|
||||
|
@ -2625,6 +2629,10 @@
|
|||
"defaultMessage": "{name} signed up",
|
||||
"id": "notification.admin.sign_up"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "{name} reported {target}",
|
||||
"id": "notification.admin.report"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "{name} has requested to follow you",
|
||||
"id": "notification.follow_request"
|
||||
|
@ -2653,6 +2661,31 @@
|
|||
],
|
||||
"path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Open report",
|
||||
"id": "report_notification.open"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Other",
|
||||
"id": "report_notification.categories.other"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Spam",
|
||||
"id": "report_notification.categories.spam"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Rule violation",
|
||||
"id": "report_notification.categories.violation"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "{count, plural, one {{count} post} other {{count} posts}} attached",
|
||||
"id": "report_notification.attached_statuses"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/notifications/components/report.json"
|
||||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
|
|
|
@ -319,6 +319,7 @@
|
|||
"navigation_bar.preferences": "Preferences",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.security": "Security",
|
||||
"notification.admin.report": "{name} reported {target}",
|
||||
"notification.admin.sign_up": "{name} signed up",
|
||||
"notification.favourite": "{name} favourited your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
|
@ -331,6 +332,7 @@
|
|||
"notification.update": "{name} edited a post",
|
||||
"notifications.clear": "Clear notifications",
|
||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||
"notifications.column_settings.admin.report": "New reports:",
|
||||
"notifications.column_settings.admin.sign_up": "New sign-ups:",
|
||||
"notifications.column_settings.alert": "Desktop notifications",
|
||||
"notifications.column_settings.favourite": "Favourites:",
|
||||
|
@ -436,6 +438,11 @@
|
|||
"report.thanks.title_actionable": "Thanks for reporting, we'll look into this.",
|
||||
"report.unfollow": "Unfollow @{name}",
|
||||
"report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
|
||||
"report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached",
|
||||
"report_notification.categories.other": "Other",
|
||||
"report_notification.categories.spam": "Spam",
|
||||
"report_notification.categories.violation": "Rule violation",
|
||||
"report_notification.open": "Open report",
|
||||
"search.placeholder": "Search",
|
||||
"search_popout.search_format": "Advanced search format",
|
||||
"search_popout.tips.full_text": "Simple text returns posts you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
|
||||
|
|
|
@ -7,13 +7,13 @@
|
|||
"account.block_domain": "Bloki domajnon {domain}",
|
||||
"account.blocked": "Blokita",
|
||||
"account.browse_more_on_origin_server": "Vidi pli ĉe la originala profilo",
|
||||
"account.cancel_follow_request": "Nuligi peton de sekvado",
|
||||
"account.cancel_follow_request": "Nuligi la demandon de sekvado",
|
||||
"account.direct": "Rekte mesaĝi @{name}",
|
||||
"account.disable_notifications": "Ĉesu sciigi min kiam @{name} mesaĝi",
|
||||
"account.domain_blocked": "Domajno blokita",
|
||||
"account.edit_profile": "Redakti profilon",
|
||||
"account.enable_notifications": "Sciigi min kiam @{name} mesaĝi",
|
||||
"account.endorse": "Montri en profilo",
|
||||
"account.edit_profile": "Redakti la profilon",
|
||||
"account.enable_notifications": "Sciigi min kiam @{name} mesaĝas",
|
||||
"account.endorse": "Rekomendi ĉe via profilo",
|
||||
"account.follow": "Sekvi",
|
||||
"account.followers": "Sekvantoj",
|
||||
"account.followers.empty": "Ankoraŭ neniu sekvas tiun uzanton.",
|
||||
|
@ -22,7 +22,7 @@
|
|||
"account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}",
|
||||
"account.follows.empty": "Tiu uzanto ankoraŭ ne sekvas iun.",
|
||||
"account.follows_you": "Sekvas vin",
|
||||
"account.hide_reblogs": "Kaŝi plusendojn de @{name}",
|
||||
"account.hide_reblogs": "Kaŝi la plusendojn de @{name}",
|
||||
"account.joined": "Kuniĝis {date}",
|
||||
"account.link_verified_on": "La posedanto de tiu ligilo estis kontrolita je {date}",
|
||||
"account.locked_info": "La privateco de tiu konto estas elektita kiel fermita. La posedanto povas mane akcepti tiun, kiu povas sekvi rin.",
|
||||
|
@ -34,7 +34,7 @@
|
|||
"account.muted": "Silentigita",
|
||||
"account.posts": "Mesaĝoj",
|
||||
"account.posts_with_replies": "Mesaĝoj kaj respondoj",
|
||||
"account.report": "Signali @{name}",
|
||||
"account.report": "Raporti @{name}",
|
||||
"account.requested": "Atendo de aprobo. Alklaku por nuligi peton de sekvado",
|
||||
"account.share": "Kundividi la profilon de @{name}",
|
||||
"account.show_reblogs": "Montri la plusendojn de @{name}",
|
||||
|
@ -42,62 +42,62 @@
|
|||
"account.unblock": "Malbloki @{name}",
|
||||
"account.unblock_domain": "Malbloki {domain}",
|
||||
"account.unblock_short": "Malbloki",
|
||||
"account.unendorse": "Ne montri en profilo",
|
||||
"account.unendorse": "Ne rekomendi ĉe la profilo",
|
||||
"account.unfollow": "Ne plu sekvi",
|
||||
"account.unmute": "Malsilentigi @{name}",
|
||||
"account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
|
||||
"account.unmute_short": "Malsilentigi",
|
||||
"account_note.placeholder": "Alklaku por aldoni noton",
|
||||
"account.unmute": "Ne plu silentigi @{name}",
|
||||
"account.unmute_notifications": "Reebligi la sciigojn de @{name}",
|
||||
"account.unmute_short": "Ne plu silentigi",
|
||||
"account_note.placeholder": "Klaku por aldoni noton",
|
||||
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
|
||||
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
|
||||
"admin.dashboard.retention.average": "Averaĝa",
|
||||
"admin.dashboard.retention.cohort": "Registriĝo monato",
|
||||
"admin.dashboard.retention.cohort": "Monato de registriĝo",
|
||||
"admin.dashboard.retention.cohort_size": "Novaj uzantoj",
|
||||
"alert.rate_limited.message": "Bonvolu reprovi post {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Mesaĝkvante limigita",
|
||||
"alert.unexpected.message": "Neatendita eraro okazis.",
|
||||
"alert.unexpected.title": "Ups!",
|
||||
"alert.unexpected.title": "Aj!",
|
||||
"announcement.announcement": "Anonco",
|
||||
"attachments_list.unprocessed": "(neprilaborita)",
|
||||
"autosuggest_hashtag.per_week": "{count} semajne",
|
||||
"boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
|
||||
"bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
|
||||
"bundle_column_error.retry": "Bonvolu reprovi",
|
||||
"bundle_column_error.title": "Reta eraro",
|
||||
"bundle_column_error.retry": "Provu refoje",
|
||||
"bundle_column_error.title": "Eraro de reto",
|
||||
"bundle_modal_error.close": "Fermi",
|
||||
"bundle_modal_error.message": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
|
||||
"bundle_modal_error.retry": "Bonvolu reprovi",
|
||||
"bundle_modal_error.retry": "Provu refoje",
|
||||