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` property
main
Claire 2 years ago
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 \

@ -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'

@ -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",
"column.blocks": "Blokitaj uzantoj",
"column.bookmarks": "Legosignoj",
"column.community": "Loka templinio",
"column.direct": "Rektaj mesaĝoj",
"column.directory": "Trarigardi profilojn",
"column.domain_blocks": "Blokitaj domajnoj",
"column.favourites": "Stelumoj",
"column.favourites": "Preferaĵoj",
"column.follow_requests": "Demandoj de sekvado",
"column.home": "Hejmo",
"column.lists": "Listoj",
"column.mutes": "Silentigitaj uzantoj",
"column.notifications": "Sciigoj",
"column.pins": "Alpinglitaj mesaĝoj",
"column.public": "Fratara templinio",
"column.public": "Federata templinio",
"column_back_button.label": "Reveni",
"column_header.hide_settings": "Kaŝi agordojn",
"column_header.hide_settings": "Kaŝi la agordojn",
"column_header.moveLeft_settings": "Movi kolumnon maldekstren",
"column_header.moveRight_settings": "Movi kolumnon dekstren",
"column_header.pin": "Alpingli",
"column_header.show_settings": "Montri agordojn",
"column_header.show_settings": "Montri la agordojn",
"column_header.unpin": "Depingli",
"column_subheading.settings": "Agordado",
"column_subheading.settings": "Agordoj",
"community.column_settings.local_only": "Nur loka",
"community.column_settings.media_only": "Nur aŭdovidaĵoj",
"community.column_settings.remote_only": "Nur malproksima",
"community.column_settings.remote_only": "Nur fora",
"compose.language.change": "Ŝanĝi lingvon",
"compose.language.search": "Serĉi lingvojn...",
"compose_form.direct_message_warning_learn_more": "Lerni pli",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
"compose_form.encryption_warning": "La mesaĵoj en Mastodono ne estas ĉifrita de tutvojo. Ne kundividu sentemajn informojn ĉe Mastodono.",
"compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.",
"compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.",
"compose_form.lock_disclaimer": "Via konto ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn nur al la sekvantoj.",
"compose_form.lock_disclaimer.lock": "ŝlosita",
"compose_form.placeholder": "Kion vi pensas?",
"compose_form.poll.add_option": "Aldoni elekteblon",
@ -116,7 +116,7 @@
"compose_form.spoiler.unmarked": "Teksto ne kaŝita",
"compose_form.spoiler_placeholder": "Skribu vian averton ĉi tie",
"confirmation_modal.cancel": "Nuligi",
"confirmations.block.block_and_report": "Bloki kaj signali",
"confirmations.block.block_and_report": "Bloki kaj raporti",
"confirmations.block.confirm": "Bloki",
"confirmations.block.message": "Ĉu vi certas, ke vi volas bloki {name}?",
"confirmations.delete.confirm": "Forigi",
@ -124,7 +124,7 @@
"confirmations.delete_list.confirm": "Forigi",
"confirmations.delete_list.message": "Ĉu vi certas, ke vi volas porĉiame forigi ĉi tiun liston?",
"confirmations.discard_edit_media.confirm": "Ne konservi",
"confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?",
"confirmations.discard_edit_media.message": "Vi havas nekonservitan ŝanĝon de la priskribo aŭ de la antaŭvido de aŭdvidaĵo, ĉu vi forigu ĝin?",
"confirmations.domain_block.confirm": "Bloki la tutan domajnon",
"confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas tute bloki {domain}? Plej ofte, trafa blokado kaj silentigado sufiĉas kaj preferindas. Vi ne vidos enhavon de tiu domajno en publika templinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj.",
"confirmations.logout.confirm": "Adiaŭi",
@ -133,7 +133,7 @@
"confirmations.mute.explanation": "Ĉi-tio kaŝos mesaĝojn el ili kaj mesaĝojn kiuj mencias ilin, sed ili ankoraŭ rajtos vidi viajn mesaĝojn kaj sekvi vin.",
"confirmations.mute.message": "Ĉu vi certas, ke vi volas silentigi {name}?",
"confirmations.redraft.confirm": "Forigi kaj reskribi",
"confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi tiun mesaĝon kaj reskribi ĝin? Ĉiuj diskonigoj kaj stelumoj estos perditaj, kaj respondoj al la originala mesaĝo estos senparentaj.",
"confirmations.redraft.message": "Ĉu vi certas ke vi volas forigi kaj reskribi la mesaĝon? Ĝiaj preferitaĵoj kaj ĝiaj plusendoj estos perditaj, kaj la respondoj al la originala mesaĝo estos orfaj.",
"confirmations.reply.confirm": "Respondi",
"confirmations.reply.message": "Respondi nun anstataŭigos la mesaĝon, kiun vi nun skribas. Ĉu vi certas, ke vi volas daŭrigi?",
"confirmations.unfollow.confirm": "Ne plu sekvi",
@ -172,8 +172,8 @@
"empty_column.direct": "Vi ankoraŭ ne havas rektan mesaĝon. Kiam vi sendos aŭ ricevos iun, ĝi aperos ĉi tie.",
"empty_column.domain_blocks": "Ankoraŭ neniu domajno estas blokita.",
"empty_column.explore_statuses": "Nenio tendencas nun. Rekontrolu poste!",
"empty_column.favourited_statuses": "Vi ankoraŭ ne stelumis mesaĝon. Kiam vi stelumos iun, tiu aperos ĉi tie.",
"empty_column.favourites": "Ankoraŭ neniu stelumis tiun mesaĝon. Kiam iu faros tion, tiu aperos ĉi tie.",
"empty_column.favourited_statuses": "Vi ankoraŭ ne havas mesaĝon en la preferaĵoj. Kiam vi aldonas ion, ĝi aperos ĉi tie.",
"empty_column.favourites": "Ankoraŭ neniu preferis la mesaĝon. Kiam iu faros ĉi tion, ili aperos ĉi tie.",
"empty_column.follow_recommendations": "Ŝajnas, ke neniuj sugestoj povis esti generitaj por vi. Vi povas provi uzi serĉon por serĉi homojn, kiujn vi eble konas, aŭ esplori tendencajn kradvortojn.",
"empty_column.follow_requests": "Vi ne ankoraŭ havas iun peton de sekvado. Kiam vi ricevos unu, ĝi aperos ĉi tie.",
"empty_column.hashtag": "Ankoraŭ estas nenio per ĉi tiu kradvorto.",
@ -198,10 +198,10 @@
"explore.trending_tags": "Kradvortoj",
"follow_recommendations.done": "Farita",
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
"follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, aperos kronologie en via abonfluo. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!",
"follow_recommendations.lead": "La mesaĝoj de personoj kiujn vi sekvas, kronologie aperos en via hejma templinio. Ne timu erari, vi povas ĉesi sekvi facile iam ajn!",
"follow_request.authorize": "Rajtigi",
"follow_request.reject": "Rifuzi",
"follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas kontroli la demandojn de sekvado de ĉi tiuj kontoj permane.",
"follow_requests.unlocked_explanation": "Kvankam via konto ne estas ŝlosita, la teamo de {domain} pensis ke vi eble volas permane kontroli la demandojn de sekvado de ĉi tiuj kontoj.",
"generic.saved": "Konservita",
"getting_started.developers": "Programistoj",
"getting_started.directory": "Profilujo",
@ -237,9 +237,9 @@
"keyboard_shortcuts.direct": "malfermi la kolumnon de rektaj mesaĝoj",
"keyboard_shortcuts.down": "iri suben en la listo",
"keyboard_shortcuts.enter": "malfermi mesaĝon",
"keyboard_shortcuts.favourite": "stelumi",
"keyboard_shortcuts.favourites": "malfermi la liston de stelumoj",
"keyboard_shortcuts.federated": "Malfermi la frataran templinion",
"keyboard_shortcuts.favourite": "Aldoni la mesaĝon al preferaĵoj",
"keyboard_shortcuts.favourites": "Malfermi la liston de preferaĵoj",
"keyboard_shortcuts.federated": "Malfermi la federatan templinion",
"keyboard_shortcuts.heading": "Klavaraj mallongigoj",
"keyboard_shortcuts.home": "Malfermi la hejman templinion",
"keyboard_shortcuts.hotkey": "Rapidklavo",
@ -279,7 +279,7 @@
"lists.replies_policy.followed": "Iu sekvanta uzanto",
"lists.replies_policy.list": "Membroj de la listo",
"lists.replies_policy.none": "Neniu",
"lists.replies_policy.title": "Montri respondon al:",
"lists.replies_policy.title": "Montri respondojn al:",
"lists.search": "Serĉi inter la homoj, kiujn vi sekvas",
"lists.subheading": "Viaj listoj",
"load_pending": "{count,plural, one {# nova elemento} other {# novaj elementoj}}",
@ -312,10 +312,10 @@
"navigation_bar.personal": "Persone",
"navigation_bar.pins": "Alpinglitaj mesaĝoj",
"navigation_bar.preferences": "Preferoj",
"navigation_bar.public_timeline": "Fratara templinio",
"navigation_bar.public_timeline": "Federata templinio",
"navigation_bar.security": "Sekureco",
"notification.admin.sign_up": "{name} registris",
"notification.favourite": "{name} stelumis vian mesaĝon",
"notification.favourite": "{name} preferis vian mesaĝon",
"notification.follow": "{name} eksekvis vin",
"notification.follow_request": "{name} petis sekvi vin",
"notification.mention": "{name} menciis vin",
@ -328,10 +328,10 @@
"notifications.clear_confirmation": "Ĉu vi certas, ke vi volas porĉiame forviŝi ĉiujn viajn sciigojn?",
"notifications.column_settings.admin.sign_up": "Novaj registriĝoj:",
"notifications.column_settings.alert": "Retumilaj sciigoj",
"notifications.column_settings.favourite": "Stelumoj:",
"notifications.column_settings.favourite": "Preferaĵoj:",
"notifications.column_settings.filter_bar.advanced": "Montri ĉiujn kategoriojn",
"notifications.column_settings.filter_bar.category": "Rapida filtra breto",
"notifications.column_settings.filter_bar.show_bar": "Montru filtrilon",
"notifications.column_settings.filter_bar.show_bar": "Montri la breton de filtrilo",
"notifications.column_settings.follow": "Novaj sekvantoj:",
"notifications.column_settings.follow_request": "Novaj petoj de sekvado:",
"notifications.column_settings.mention": "Mencioj:",
@ -346,7 +346,7 @@
"notifications.column_settings.update": "Redaktoj:",
"notifications.filter.all": "Ĉiuj",
"notifications.filter.boosts": "Plusendoj",
"notifications.filter.favourites": "Stelumoj",
"notifications.filter.favourites": "Preferaĵoj",
"notifications.filter.follows": "Sekvoj",
"notifications.filter.mentions": "Mencioj",
"notifications.filter.polls": "Balotenketaj rezultoj",
@ -381,7 +381,7 @@
"privacy.unlisted.short": "Nelistigita",
"refresh": "Refreŝigu",
"regeneration_indicator.label": "Ŝargado…",
"regeneration_indicator.sublabel": "Via hejma fluo pretiĝas!",
"regeneration_indicator.sublabel": "Via abonfluo estas preparata!",
"relative_time.days": "{number}t",
"relative_time.full.days": "{number, plural, one {# day} other {# days}} ago",
"relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago",
@ -397,18 +397,18 @@
"report.block": "Bloki",
"report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
"report.categories.other": "Aliaj",
"report.categories.spam": "Spamo",
"report.categories.spam": "Trudaĵo",
"report.categories.violation": "Content violates one or more server rules",
"report.category.subtitle": "Elektu la plej bonan kongruon",
"report.category.title": "Diru al ni kio okazas pri ĉi tiu {type}",
"report.category.title_account": "profilo",
"report.category.title_status": "afiŝo",
"report.close": "Farita",
"report.comment.title": "Is there anything else you think we should know?",
"report.comment.title": "Ĉu estas io alia kion vi pensas ke ni devas scii?",
"report.forward": "Plusendi al {target}",
"report.forward_hint": "La konto estas en alia servilo. Ĉu sendi sennomigitan kopion de la signalo ankaŭ tien?",
"report.forward_hint": "La konto estas de alia servilo. Ĉu vi volas sendi anoniman kopion de la informo ankaŭ al tie?",
"report.mute": "Silentigi",
"report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke si estas silentigitaj.",
"report.mute_explanation": "Vi ne vidos iliajn afiŝojn. Ili ankoraŭ povas sekvi vin kaj vidi viajn afiŝojn, kaj ne scios ke ili estas silentigitaj.",
"report.next": "Sekva",
"report.placeholder": "Pliaj komentoj",
"report.reasons.dislike": "Mi ne ŝatas ĝin",
@ -417,20 +417,20 @@
"report.reasons.other_description": "La problemo ne taŭgas en aliaj kategorioj",
"report.reasons.spam": "Ĝi estas trudaĵo",
"report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies",
"report.reasons.violation": "Ĝi malrespektas servilajn regulojn",
"report.reasons.violation": "Ĝi malobservas la regulojn de la servilo",
"report.reasons.violation_description": "You are aware that it breaks specific rules",
"report.rules.subtitle": "Elektu ĉiujn, kiuj validas",
"report.rules.title": "Kiuj reguloj estas malobservataj?",
"report.statuses.subtitle": "Elektu ĉiujn, kiuj validas",
"report.statuses.title": "Are there any posts that back up this report?",
"report.submit": "Sendi",
"report.target": "Signali {target}",
"report.target": "Raporto pri {target}",
"report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:",
"report.thanks.take_action_actionable": "While we review this, you can take action against @{name}:",
"report.thanks.title": "Ĉu vi ne volas vidi ĉi tion?",
"report.thanks.title_actionable": "Dankon pro raporti, ni esploros ĉi tion.",
"report.unfollow": "Malsekvi @{name}",
"report.unfollow_explanation": "Vi estas sekvanta ĉi tiun konton. Por ne plu vidi ties afiŝojn en via hejma templinio, malsekvu ilin.",
"report.unfollow_explanation": "Vi sekvas ĉi tiun konton. Por ne plu vidi ĝiajn abonfluojn en via hejma templinio, ĉesu sekvi ĝin.",
"search.placeholder": "Serĉi",
"search_popout.search_format": "Detala serĉo",
"search_popout.tips.full_text": "Simplaj tekstoj montras la mesaĝojn, kiujn vi skribis, stelumis, diskonigis, aŭ en kiuj vi estis menciita, sed ankaŭ kongruajn uzantnomojn, montratajn nomojn, kaj kradvortojn.",
@ -459,7 +459,7 @@
"status.edited": "Redaktita {date}",
"status.edited_x_times": "Redactita {count, plural, one {{count} fojon} other {{count} fojojn}}",
"status.embed": "Enkorpigi",
"status.favourite": "Stelumi",
"status.favourite": "Preferaĵo",
"status.filtered": "Filtrita",
"status.history.created": "{name} kreis {date}",
"status.history.edited": "{name} redaktis {date}",
@ -469,8 +469,8 @@
"status.more": "Pli",
"status.mute": "Silentigi @{name}",
"status.mute_conversation": "Silentigi konversacion",
"status.open": "Grandigi ĉi tiun mesaĝon",
"status.pin": "Alpingli profile",
"status.open": "Disvolvi la mesaĝon",
"status.pin": "Alpingli al la profilo",
"status.pinned": "Alpinglita mesaĝo",
"status.read_more": "Legi pli",
"status.reblog": "Plusendi",
@ -481,20 +481,20 @@
"status.remove_bookmark": "Forigi legosignon",
"status.reply": "Respondi",
"status.replyAll": "Respondi al la fadeno",
"status.report": "Signali @{name}",
"status.report": "Raporti @{name}",
"status.sensitive_warning": "Tikla enhavo",
"status.share": "Diskonigi",
"status.show_less": "Malgrandigi",
"status.show_less_all": "Malgrandigi ĉiujn",
"status.show_more": "Grandigi",
"status.show_more_all": "Malfoldi ĉiun",
"status.show_thread": "Montri la fadenon",
"status.share": "Kundividi",
"status.show_less": "Montri malpli",
"status.show_less_all": "Montri malpli ĉiun",
"status.show_more": "Montri pli",
"status.show_more_all": "Montri pli ĉiun",
"status.show_thread": "Montri la mesaĝaron",
"status.uncached_media_warning": "Nedisponebla",
"status.unmute_conversation": "Malsilentigi la konversacion",
"status.unpin": "Depingli de profilo",
"suggestions.dismiss": "Forigi la proponon",
"suggestions.header": "Vi povus interesiĝi pri…",
"tabs_bar.federated_timeline": "Fratara templinio",
"tabs_bar.federated_timeline": "Federata",
"tabs_bar.home": "Hejmo",
"tabs_bar.local_timeline": "Loka templinio",
"tabs_bar.notifications": "Sciigoj",
@ -539,7 +539,7 @@
"video.close": "Fermi la videon",
"video.download": "Elŝuti dosieron",
"video.exit_fullscreen": "Eksigi plenekrana",
"video.expand": "Grandigi la videon",
"video.expand": "Pligrandigi la videon",
"video.fullscreen": "Igi plenekrana",
"video.hide": "Kaŝi la videon",
"video.mute": "Silentigi",

@ -8,7 +8,7 @@
"account.blocked": "Bloqueada",
"account.browse_more_on_origin_server": "Busca máis no perfil orixinal",
"account.cancel_follow_request": "Desbotar solicitude de seguimento",
"account.direct": "Mensaxe directa @{name}",
"account.direct": "Mensaxe directa a @{name}",
"account.disable_notifications": "Deixar de notificarme cando @{name} publica",
"account.domain_blocked": "Dominio agochado",
"account.edit_profile": "Editar perfil",

@ -106,7 +106,7 @@
"compose_form.poll.remove_option": "เอาตัวเลือกนี้ออก",
"compose_form.poll.switch_to_multiple": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตหลายตัวเลือก",
"compose_form.poll.switch_to_single": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตตัวเลือกเดี่ยว",
"compose_form.publish": "Publish",
"compose_form.publish": "เผยแพร่",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "บันทึกการเปลี่ยนแปลง",
"compose_form.sensitive.hide": "{count, plural, other {ทำเครื่องหมายสื่อว่าละเอียดอ่อน}}",

@ -1,10 +1,34 @@
import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
import { List as ImmutableList, fromJS } from 'immutable';
import { FILTERS_IMPORT } from '../actions/importer';
import { Map as ImmutableMap, is, fromJS } from 'immutable';
export default function filters(state = ImmutableList(), action) {
const normalizeFilter = (state, filter) => {
const normalizedFilter = fromJS({
id: filter.id,
title: filter.title,
context: filter.context,
filter_action: filter.filter_action,
expires_at: filter.expires_at ? Date.parse(filter.expires_at) : null,
});
if (is(state.get(filter.id), normalizedFilter)) {
return state;
} else {
return state.set(filter.id, normalizedFilter);
}
};
const normalizeFilters = (state, filters) => {
filters.forEach(filter => {
state = normalizeFilter(state, filter);
});
return state;
};
export default function filters(state = ImmutableMap(), action) {
switch(action.type) {
case FILTERS_FETCH_SUCCESS:
return fromJS(action.filters);
case FILTERS_IMPORT:
return normalizeFilters(state, action.filters);
default:
return state;
}

@ -28,7 +28,7 @@ import {
} from '../actions/app';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
const initialState = ImmutableMap({
@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({
account: notification.account.id,
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null,
});
const normalizeNotification = (state, notification, usePendingItems) => {

@ -39,6 +39,7 @@ const initialState = ImmutableMap({
status: false,
update: false,
'admin.sign_up': false,
'admin.report': false,
}),
quickFilter: ImmutableMap({
@ -60,6 +61,7 @@ const initialState = ImmutableMap({
status: true,
update: true,
'admin.sign_up': true,
'admin.report': true,
}),
sounds: ImmutableMap({
@ -72,6 +74,7 @@ const initialState = ImmutableMap({
status: true,
update: true,
'admin.sign_up': true,
'admin.report': true,
}),
}),

@ -40,15 +40,15 @@ const toServerSideType = columnType => {
const escapeRegExp = string =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
const regexFromFilters = filters => {
if (filters.size === 0) {
const regexFromKeywords = keywords => {
if (keywords.size === 0) {
return null;
}
return new RegExp(filters.map(filter => {
let expr = escapeRegExp(filter.get('phrase'));
return new RegExp(keywords.map(keyword_filter => {
let expr = escapeRegExp(keyword_filter.get('keyword'));
if (filter.get('whole_word')) {
if (keyword_filter.get('whole_word')) {
if (/^[\w]/.test(expr)) {
expr = `\\b${expr}`;
}
@ -62,27 +62,15 @@ const regexFromFilters = filters => {
}).join('|'), 'i');
};
// Memoize the filter regexps for each valid server contextType
const makeGetFiltersRegex = () => {
let memo = {};
return (state, { contextType }) => {
if (!contextType) return ImmutableList();
const getFilters = (state, { contextType }) => {
if (!contextType) return null;
const serverSideType = toServerSideType(contextType);
const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
const now = new Date();
if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
const regex = regexFromFilters(filters);
memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
}
return memo[serverSideType].results;
};
return state.get('filters').filter((filter) => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || filter.get('expires_at') > now));
};
export const getFiltersRegex = makeGetFiltersRegex();
export const makeGetStatus = () => {
return createSelector(
[
@ -90,10 +78,10 @@ export const makeGetStatus = () => {
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFiltersRegex,
getFilters,
],
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
if (!statusBase) {
return null;
}
@ -104,13 +92,16 @@ export const makeGetStatus = () => {
statusReblog = null;
}
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
let filtered = false;
if ((accountReblog || accountBase).get('id') !== me && filters) {
let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
return null;
}
const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
if (!filterResults.isEmpty()) {
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
}
}
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
@ -152,14 +143,15 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
return arr;
});
export const makeGetNotification = () => {
return createSelector([
export const makeGetNotification = () => createSelector([
(_, base) => base,
(state, _, accountId) => state.getIn(['accounts', accountId]),
], (base, account) => {
return base.set('account', account);
});
};
], (base, account) => base.set('account', account));
export const makeGetReport = () => createSelector([
(_, base) => base,
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
], (base, targetAccount) => base.set('target_account', targetAccount));
export const getAccountGallery = createSelector([
(state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),

@ -3,6 +3,7 @@ import loadPolyfills from '../mastodon/load_polyfills';
import ready from '../mastodon/ready';
import { start } from '../mastodon/common';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import 'cocoon-js-vanilla';
start();

@ -75,6 +75,13 @@ $content-width: 840px;
height: 100px;
}
.logo--wordmark {
display: inherit;
margin: inherit;
width: inherit;
height: 20px;
}
@media screen and (max-width: $no-columns-breakpoint) {
& > a:first-child {
display: none;
@ -924,7 +931,8 @@ a.name-tag,
text-align: center;
}
.applications-list__item {
.applications-list__item,
.filters-list__item {
padding: 15px 0;
background: $ui-base-color;
border: 1px solid lighten($ui-base-color, 4%);
@ -932,7 +940,8 @@ a.name-tag,
margin-top: 15px;
}
.announcements-list {
.announcements-list,
.filters-list {
border: 1px solid lighten($ui-base-color, 4%);
border-radius: 4px;
@ -985,6 +994,33 @@ a.name-tag,
}
}
.filters-list__item {
&__title {
display: flex;
justify-content: space-between;
margin-bottom: 0;
}
&__permissions {
margin-top: 0;
margin-bottom: 10px;
}
.expiration {
font-size: 13px;
}
&.expired {
.expiration {
color: lighten($error-red, 12%);
}
.permissions-list__item__icon {
color: $dark-text-color;
}
}
}
.dashboard__counters.admin-account-counters {
margin-top: 10px;
}

@ -959,6 +959,21 @@
width: 100%;
clear: both;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&__button {
display: inline;
color: lighten($ui-highlight-color, 8%);
border: 0;
background: transparent;
padding: 0;
font-size: inherit;
line-height: inherit;
&:hover,
&:active {
text-decoration: underline;
}
}
}
.status__prepend-icon-wrapper {
@ -1355,6 +1370,8 @@ a .account__avatar {
.account__avatar-overlay {
@include avatar-size(48px);
position: relative;
&-base {
@include avatar-radius;
@include avatar-size(36px);
@ -1620,6 +1637,33 @@ a.account__display-name {
}
}
.notification__report {
padding: 8px 10px;
padding-left: 68px;
position: relative;
border-bottom: 1px solid lighten($ui-base-color, 8%);
min-height: 54px;
&__details {
display: flex;
justify-content: space-between;
align-items: center;
color: $darker-text-color;
font-size: 15px;
line-height: 22px;
strong {
font-weight: 500;
}
}
&__avatar {
position: absolute;
left: 10px;
top: 10px;
}
}
.notification__message {
margin: 0 10px 0 68px;
padding: 8px 0 0;
@ -2360,6 +2404,16 @@ a.account__display-name {
padding-top: 15px;
}
.notification__report {
padding: 15px 15px 15px (48px + 15px * 2);
min-height: 48px + 2px;
&__avatar {
left: 15px;
top: 17px;
}
}
.status {
padding: 15px 15px 15px (48px + 15px * 2);
min-height: 48px + 2px;

@ -1070,3 +1070,34 @@ code {
}
}
}
.keywords-table {
thead {
th {
white-space: nowrap;
}
th:first-child {
width: 100%;
}
}
tfoot {
td {
border: 0;
}
}
.input.string {
margin-bottom: 0;
}
.label_input__wrapper {
margin-top: 10px;
}
.table-action-link {
margin-top: 10px;
white-space: nowrap;
}
}

@ -50,7 +50,7 @@ class ActivityPub::Parser::MediaAttachmentParser
components = begin
blurhash = @json['blurhash']
if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
if blurhash.present? && /^[\w#$%*+,-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
Blurhash.components(blurhash)
end
end

@ -401,7 +401,6 @@ class FeedManager
def filter_from_home?(status, receiver_id, crutches)
return false if receiver_id == status.account_id
return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
return true if phrase_filtered?(status, receiver_id, :home)
check_for_blocks = crutches[:active_mentions][status.id] || []
check_for_blocks.concat([status.account_id])
@ -437,7 +436,6 @@ class FeedManager
# @return [Boolean]
def filter_from_mentions?(status, receiver_id)
return true if receiver_id == status.account_id
return true if phrase_filtered?(status, receiver_id, :notifications)
# This filter is called from NotifyService, but already after the sender of
# the notification has been checked for mute/block. Therefore, it's not
@ -476,34 +474,6 @@ class FeedManager
false
end
# Check if the status hits a phrase filter
# @param [Status] status
# @param [Integer] receiver_id
# @param [Symbol] context
# @return [Boolean]
def phrase_filtered?(status, receiver_id, context)
active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a
active_filters.select! { |filter| filter.context.include?(context.to_s) && !filter.expired? }
active_filters.map! do |filter|
if filter.whole_word
sb = /\A[[:word:]]/.match?(filter.phrase) ? '\b' : ''
eb = /[[:word:]]\z/.match?(filter.phrase) ? '\b' : ''
/(?mix:#{sb}#{Regexp.escape(filter.phrase)}#{eb})/
else
/#{Regexp.escape(filter.phrase)}/i
end
end
return false if active_filters.empty?
combined_regex = Regexp.union(active_filters)
combined_regex.match?(status.proper.searchable_text)
end
# Adds a status to an account's feed, returning true if a status was
# added, and false if it was not added to the feed. Note that this is
# an internal helper: callers must call trim or push updates if

@ -247,6 +247,19 @@ module AccountInteractions
account_pins.where(target_account: account).exists?
end
def status_matches_filters(status)
active_filters = CustomFilter.cached_filters_for(id)
filter_matches = active_filters.filter_map do |filter, rules|
next if rules[:keywords].blank?
match = rules[:keywords].match(status.proper.searchable_text)
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
end
filter_matches
end
def followers_for_local_distribution
followers.local
.joins(:user)

@ -3,18 +3,22 @@
#
# Table name: custom_filters
#
# id :bigint(8) not null, primary key
# account_id :bigint(8)
# id :bigint not null, primary key
# account_id :bigint
# expires_at :datetime
# phrase :text default(""), not null
# context :string default([]), not null, is an Array
# whole_word :boolean default(TRUE), not null
# irreversible :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# action :integer default(0), not null
#
class CustomFilter < ApplicationRecord
self.ignored_columns = %w(whole_word irreversible)
alias_attribute :title, :phrase
alias_attribute :filter_action, :action
VALID_CONTEXTS = %w(
home
notifications
@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord
include Expireable
include Redisable
enum action: [:warn, :hide], _suffix: :action
belongs_to :account
has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy
accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true
validates :phrase, :context, presence: true
validates :title, :context, presence: true
validate :context_must_be_valid
validate :irreversible_must_be_within_context
scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) }
before_validation :clean_up_contexts
after_commit :remove_cache
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
def expires_in
return @expires_in if defined?(@expires_in)
@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
end
private
def irreversible=(value)
self.action = value ? :hide : :warn
end
def clean_up_contexts
self.context = Array(context).map(&:strip).filter_map(&:presence)
def irreversible?
hide_action?
end
def self.cached_filters_for(account_id)
active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do
scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()'))
scope.to_a.group_by(&:custom_filter).map do |filter, keywords|
keywords.map! do |keyword|
if keyword.whole_word
sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : ''
eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : ''
/(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/
else
/#{Regexp.escape(keyword.keyword)}/i
end
end
[filter, { keywords: Regexp.union(keywords) }]
end
end.to_a
def remove_cache
Rails.cache.delete("filters:#{account_id}")
active_filters.select { |custom_filter, _| !custom_filter.expired? }
end
def prepare_cache_invalidation!
@should_invalidate_cache = true
end
def invalidate_cache!
return unless @should_invalidate_cache
@should_invalidate_cache = false
Rails.cache.delete("filters:v3:#{account_id}")
redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed))
redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed))
end
def context_must_be_valid
errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
private
def clean_up_contexts
self.context = Array(context).map(&:strip).filter_map(&:presence)
end
def irreversible_must_be_within_context
errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications')
def context_must_be_valid
errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) }
end
end

@ -0,0 +1,34 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: custom_filter_keywords
#
# id :bigint not null, primary key
# custom_filter_id :bigint not null
# keyword :text default(""), not null
# whole_word :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CustomFilterKeyword < ApplicationRecord
belongs_to :custom_filter
validates :keyword, presence: true
alias_attribute :phrase, :keyword
before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
after_commit :invalidate_cache!
private
def prepare_cache_invalidation!
custom_filter.prepare_cache_invalidation!
end
def invalidate_cache!
custom_filter.invalidate_cache!
end
end

@ -11,6 +11,7 @@
#
class DomainAllow < ApplicationRecord
include Paginable
include DomainNormalizable
include DomainMaterializable

@ -37,6 +37,7 @@ class Notification < ApplicationRecord
poll
update
admin.sign_up
admin.report
).freeze
TARGET_STATUS_INCLUDES_BY_TYPE = {
@ -46,6 +47,7 @@ class Notification < ApplicationRecord
favourite: [favourite: :status],
poll: [poll: :status],
update: :status,
'admin.report': [report: :target_account],
}.freeze
belongs_to :account, optional: true
@ -58,6 +60,7 @@ class Notification < ApplicationRecord
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 :report, foreign_key: 'activity_id', optional: true
validates :type, inclusion: { in: TYPES }
@ -146,7 +149,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id

@ -1,6 +1,14 @@
# frozen_string_literal: true
class DomainAllowPolicy < ApplicationPolicy
def index?
admin?
end
def show?
admin?
end
def create?
admin?
end

@ -0,0 +1,5 @@
# frozen_string_literal: true
class FilterResultPresenter < ActiveModelSerializers::Model
attributes :filter, :keyword_matches
end

@ -2,7 +2,7 @@
class StatusRelationshipsPresenter
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map
:bookmarks_map, :filters_map
def initialize(statuses, current_account_id = nil, **options)
if current_account_id.nil?
@ -11,12 +11,14 @@ class StatusRelationshipsPresenter
@bookmarks_map = {}
@mutes_map = {}
@pins_map = {}
@filters_map = {}
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@ -24,4 +26,24 @@ class StatusRelationshipsPresenter
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
end
end
private
def build_filters_map(statuses, current_account_id)
active_filters = CustomFilter.cached_filters_for(current_account_id)
@filters_map = statuses.each_with_object({}) do |status, h|
filter_matches = active_filters.filter_map do |filter, rules|
next if rules[:keywords].blank?
match = rules[:keywords].match(status.proper.searchable_text)
FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil?
end
unless filter_matches.empty?
h[status.id] = filter_matches
h[status.reblog_of_id] = filter_matches if status.reblog?
end
end
end
end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::Admin::DomainAllowSerializer < ActiveModel::Serializer
attributes :id, :domain, :created_at
def id
object.id.to_s
end
end

@ -2,7 +2,7 @@
class REST::Admin::ReportSerializer < ActiveModel::Serializer
attributes :id, :action_taken, :action_taken_at, :category, :comment,
:created_at, :updated_at
:forwarded, :created_at, :updated_at
has_one :account, serializer: REST::Admin::AccountSerializer
has_one :target_account, serializer: REST::Admin::AccountSerializer

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::FilterKeywordSerializer < ActiveModel::Serializer
attributes :id, :keyword, :whole_word
def id
object.id.to_s
end
end

@ -0,0 +1,6 @@
# frozen_string_literal: true
class REST::FilterResultSerializer < ActiveModel::Serializer
belongs_to :filter, serializer: REST::FilterSerializer
has_many :keyword_matches
end

@ -1,10 +1,14 @@
# frozen_string_literal: true
class REST::FilterSerializer < ActiveModel::Serializer
attributes :id, :phrase, :context, :whole_word, :expires_at,
:irreversible
attributes :id, :title, :context, :expires_at, :filter_action
has_many :keywords, serializer: REST::FilterKeywordSerializer, if: :rules_requested?
def id
object.id.to_s
end
def rules_requested?
instance_options[:rules_requested]
end
end

@ -5,6 +5,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
def id
object.id.to_s
@ -13,4 +14,8 @@ class REST::NotificationSerializer < ActiveModel::Serializer
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?
object.type == :'admin.report'
end
end

@ -1,7 +1,10 @@
# frozen_string_literal: true
class REST::ReportSerializer < ActiveModel::Serializer
attributes :id, :action_taken
attributes :id, :action_taken, :action_taken_at, :category, :comment,
:forwarded, :created_at, :status_ids, :rule_ids
has_one :target_account, serializer: REST::AccountSerializer
def id
object.id.to_s

@ -14,6 +14,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :bookmarked, if: :current_user?
attribute :pinned, if: :pinnable?
attribute :local_only if :local?
has_many :filtered, serializer: REST::FilterResultSerializer, if: :current_user?
attribute :content, unless: :source_requested?
attribute :text, if: :source_requested?
@ -122,6 +123,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
def filtered
if instance_options && instance_options[:relationships]
instance_options[:relationships].filters_map[object.id] || []
else
current_user.account.status_matches_filters(object)
end
end
def pinnable?
current_user? &&
current_user.account_id == object.account_id &&

@ -0,0 +1,26 @@
# frozen_string_literal: true
class REST::V1::FilterSerializer < ActiveModel::Serializer
attributes :id, :phrase, :context, :whole_word, :expires_at,
:irreversible
delegate :context, :expires_at, to: :custom_filter
def id
object.id.to_s
end
def phrase
object.keyword
end
def irreversible
custom_filter.irreversible?
end
private
def custom_filter
object.custom_filter
end
end

@ -39,8 +39,8 @@ class ReportService < BaseService
return if @report.unresolved_siblings?
User.staff.includes(:account).each do |u|
next unless u.allows_report_emails?
AdminMailer.new_report(u.account, @report).deliver_later
LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report')
AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails?
end
end

@ -1,16 +0,0 @@
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :phrase, as: :string, wrapper: :with_label, hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt')
.fields-group
= f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false
%hr.spacer/
.fields-group
= f.input :irreversible, wrapper: :with_label
.fields-group
= f.input :whole_word, wrapper: :with_label

@ -0,0 +1,32 @@
.filters-list__item{ class: [filter.expired? && 'expired'] }
= link_to edit_filter_path(filter), class: 'filters-list__item__title' do
= filter.title
- if filter.expires?
.expiration{ title: t('filters.index.expires_on', date: l(filter.expires_at)) }
- if filter.expired?
= t('invites.expired')
- else
= t('filters.index.expires_in', distance: distance_of_time_in_words_to_now(filter.expires_at))
.filters-list__item__permissions
%ul.permissions-list
- unless filter.keywords.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('paragraph')
.permissions-list__item__text
.permissions-list__item__text__title
= t('filters.index.keywords', count: filter.keywords.size)
.permissions-list__item__text__type
- keywords = filter.keywords.map(&:keyword)
- keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
= keywords.join(', ')
.announcements-list__item__action-bar
.announcements-list__item__meta
= t('filters.index.contexts', contexts: filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', '))
%div
= table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter)
= table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

@ -0,0 +1,33 @@
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :title, as: :string, wrapper: :with_label, hint: false
.fields-row__column.fields-row__column-6.fields-group
= f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, include_blank: I18n.t('invites.expires_in_prompt')
.fields-group
= f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false
%hr.spacer/
.fields-group
= f.input :filter_action, as: :radio_buttons, collection: %i(warn hide), include_blank: false, wrapper: :with_block_label, label_method: ->(action) { safe_join([t("simple_form.labels.filters.actions.#{action}"), content_tag(:span, t("simple_form.hints.filters.actions.#{action}"), class: 'hint')]) }, hint: t('simple_form.hints.filters.action'), required: true
%hr.spacer/
%h4= t('filters.edit.keywords')
.table-wrapper
%table.table.keywords-table
%thead
%tr
%th= t('simple_form.labels.defaults.phrase')
%th= t('simple_form.labels.defaults.whole_word')
%th
%tbody
= f.simple_fields_for :keywords do |keyword|
= render 'keyword_fields', f: keyword
%tfoot
%tr
%td{ colspan: 3}
= link_to_add_association f, :keywords, class: 'table-action-link', partial: 'keyword_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do
= safe_join([fa_icon('plus'), t('filters.edit.add_keyword')])

@ -0,0 +1,8 @@
%tr.nested-fields
%td= f.input :keyword, as: :string
%td
.label_input__wrapper= f.input_field :whole_word
%td
= f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the <tr/>
= link_to_remove_association(f, class: 'table-action-link') do
= safe_join([fa_icon('times'), t('filters.index.delete')])

@ -2,7 +2,7 @@
= t('filters.edit.title')
= simple_form_for @filter, url: filter_path(@filter), method: :put do |f|
= render 'fields', f: f
= render 'filter_fields', f: f
.actions
= f.button :button, t('generic.save_changes'), type: :submit

@ -7,18 +7,5 @@
- if @filters.empty?
%div.muted-hint.center-text= t 'filters.index.empty'
- else
.table-wrapper
%table.table
%thead
%tr
%th= t('simple_form.labels.defaults.phrase')
%th= t('simple_form.labels.defaults.context')
%th
%tbody
- @filters.each do |filter|
%tr
%td= filter.phrase
%td= filter.context.map { |context| I18n.t("filters.contexts.#{context}") }.join(', ')
%td
= table_link_to 'pencil', t('filters.edit.title'), edit_filter_path(filter)
= table_link_to 'times', t('filters.index.delete'), filter_path(filter), method: :delete
.applications-list
= render partial: 'filter', collection: @filters

@ -2,7 +2,7 @@
= t('filters.new.title')
= simple_form_for @filter, url: filters_path do |f|
= render 'fields', f: f
= render 'filter_fields', f: f
.actions
= f.button :button, t('filters.new.title'), type: :submit
= f.button :button, t('filters.new.save'), type: :submit

@ -21,6 +21,14 @@ id:
username:
invalid: hanya boleh berisi huruf, angka, dan garis bawah
reserved: sudah dipesan
admin/webhook:
attributes:
url:
invalid: bukan URL valid
doorkeeper/application:
attributes:
website:
invalid: bukan URL valid
status:
attributes:
reblog:

@ -21,6 +21,14 @@ is:
username:
invalid: má aðeins innihalda bókstafi, tölur og undirstrik
reserved: er frátekið
admin/webhook:
attributes:
url:
invalid: er ekki gild vefslóð
doorkeeper/application:
attributes:
website:
invalid: er ekki gild vefslóð
status:
attributes:
reblog:

@ -21,6 +21,14 @@ ko:
username:
invalid: 영문자, 숫자, _만 사용 가능
reserved: 이미 예약되어 있습니다
admin/webhook:
attributes:
url:
invalid: 올바른 URL이 아닙니다
doorkeeper/application:
attributes:
website:
invalid: 올바른 URL이 아닙니다
status:
attributes:
reblog:

@ -21,6 +21,14 @@ nl:
username:
invalid: alleen letters, nummers en underscores
reserved: gereserveerd
admin/webhook:
attributes:
url:
invalid: is een ongeldige URL
doorkeeper/application:
attributes:
website:
invalid: is een ongeldige URL
status:
attributes:
reblog:

@ -21,6 +21,14 @@ th:
username:
invalid: ต้องมีเฉพาะตัวอักษร, ตัวเลข และขีดล่างเท่านั้น
reserved: ถูกสงวนไว้
admin/webhook:
attributes:
url:
invalid: ไม่ใช่ URL ที่ถูกต้อง
doorkeeper/application:
attributes:
website:
invalid: ไม่ใช่ URL ที่ถูกต้อง
status:
attributes:
reblog:

@ -1124,15 +1124,24 @@ en:
public: Public timelines
thread: Conversations
edit:
add_keyword: Add keyword
keywords: Keywords
title: Edit filter
errors:
deprecated_api_multiple_keywords: These parameters cannot be changed from this application because they apply to more than one filter keyword. Use a more recent application or the web interface.
invalid_context: None or invalid context supplied
invalid_irreversible: Irreversible filtering only works with home or notifications context
index:
contexts: Filters in %{contexts}
delete: Delete
empty: You have no filters.
expires_in: Expires in %{distance}
expires_on: Expires on %{date}
keywords:
one: "%{count} keyword"
other: "%{count} keywords"
title: Filters
new:
save: Save new filter
title: Add new filter
footer:
developers: Developers
@ -1251,6 +1260,8 @@ en:
copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:'
notification_mailer:
admin:
report:
subject: "%{name} submitted a report"
sign_up:
subject: "%{name} signed up"
digest:

@ -22,9 +22,9 @@ eo:
federation_hint_html: Per konto ĉe %{instance}, vi povos sekvi homojn ĉe iu ajn Mastodon nodo kaj preter.
get_apps: Provu telefonan aplikaĵon
hosted_on: "%{domain} estas nodo de Mastodon"
instance_actor_flash: |
Ĉi tiu konto estas virtuala ulo uzata por reprezenti la servilon mem kaj ne iun apartan uzanton.
Ĝi estas uzata por frataraj celoj kaj ĝi ne devus esti blokita krom se vi volas bloki la tutan servilon, tiuokaze vi devus uzi domajnan blokadon.
instance_actor_flash: 'Ĉi tiu konto estas virtuala aganto uzata por reprezenti la servilon mem kaj neniun individuan uzanton. Ĝi estas uzata por celoj de la federaĵo kaj devas ne esti brokita se vi ne volas bloki la tutan servilon, tiuokaze vi devas uzi blokadon de domajno.
'
learn_more: Lerni pli
logout_before_registering: Vi jam salutis.
privacy_policy: Privateca politiko
@ -179,8 +179,8 @@ eo:
sensitized: markita tikla
shared_inbox_url: URL de kunhavigita leterkesto
show:
created_reports: Kreitaj signaloj
targeted_reports: Signalitaj de aliaj
created_reports: Kreitaj raportoj
targeted_reports: Raporitaj de alia
silence: Kaŝi
silenced: Silentigita
statuses: Mesaĝoj
@ -1164,10 +1164,10 @@ eo:
one: "%{count} voĉdono"
other: "%{count} voĉdonoj"
vote: Voĉdoni
show_more: Malfoldi
show_more: Montri pli
show_newer: Montri pli novajn
show_older: Montri pli malnovajn
show_thread: Montri la fadenon
show_thread: Montri la mesaĝaron
sign_in_to_participate: Ensaluti por partopreni en la konversacio
title: "%{name}: “%{quote}”"
visibilities:
@ -1255,7 +1255,7 @@ eo:
review_preferences_action: Ŝanĝi preferojn
review_preferences_step: Estu certa ke vi agordis viajn preferojn, kiel kiujn retmesaĝojn vi ŝatus ricevi, aŭ kiun dekomencan privatecan nivelon vi ŝatus ke viaj mesaĝoj havu. Se tio ne ĝenas vin, vi povas ebligi aŭtomatan ekigon de GIF-oj.
subject: Bonvenon en Mastodon
tip_federated_timeline: La fratara templinio estas antaŭvido de la reto de Mastodon. Sed ĝi enhavas nur homojn, kiuj estas sekvataj de aliaj homoj de via nodo, do ĝi ne estas kompleta.
tip_federated_timeline: La federata templinio estas rekta vido de la reto de Mastodon. Sed ĝi inkluzivas nur personojn kiujn via najbaroj abonas, do ĝi ne estas kompleta.
tip_following: Vi dekomence sekvas la administrantojn de via servilo. Por trovi pli da interesaj homoj, rigardu la lokan kaj frataran templiniojn.
tip_local_timeline: La loka templinio estas antaŭvido de la homoj en %{instance}. Ĉi tiuj estas viaj apudaj najbaroj!
tip_mobile_webapp: Se via telefona retumilo proponas al vi aldoni Mastodon al via hejma ekrano, vi povas ricevi puŝsciigojn. Tio multmaniere funkcias kiel operaciuma aplikaĵo!

@ -941,7 +941,7 @@ gl:
warning: Ten moito tino con estos datos. Non os compartas nunca con ninguén!
your_token: O seu testemuño de acceso
auth:
apply_for_account: Solicite un convite
apply_for_account: Solicita un convite
change_password: Contrasinal
checkbox_agreement_html: Acepto as <a href="%{rules_path}" target="_blank">regras do servidor</a> e os <a href="%{terms_path}" target="_blank">termos do servizo</a>
checkbox_agreement_without_rules_html: Acepto os <a href="%{terms_path}" target="_blank">termos do servizo</a>
@ -954,7 +954,7 @@ gl:
didnt_get_confirmation: Non recibiches as instruccións de confirmación?
dont_have_your_security_key: "¿Non tes a túa chave de seguridade?"
forgot_password: Non lembras o contrasinal?
invalid_reset_password_token: O testemuño para restablecer o contrasinal non é válido ou caducou. Por favor solicite un novo.
invalid_reset_password_token: O token para restablecer o contrasinal non é válido ou caducou. Por favor solicita un novo.
link_to_otp: Escribe o código do segundo factor do móbil ou un código de recuperación
link_to_webauth: Usa o teu dispositivo de chave de seguridade
log_in_with: Accede con

@ -853,13 +853,25 @@ is:
empty: Þú hefur ekki enn skilgreint neinar aðvaranaforstillingar.
title: Sýsla með forstilltar aðvaranir
webhooks:
add_new: Bæta við endapunkti
delete: Eyða
description_html: "<strong>webhook-vefkrækja</strong> gerir Mastodon kleift að ýta <strong>rauntíma-tilkynningum</strong> um valda atburði til þinna eigin forrita, þannig að þau forrit getir <strong>sett sjálfvirk viðbrögð í gang</strong>."
disable: Gera óvirkt
disabled: Óvirkt
edit: Breyta endapunkti
empty: Þú ert ekki enn búin/n að stilla neina endapunkta á webhook-vefkrækjum.
enable: Virkja
enabled: Virkt
enabled_events:
one: 1 virkjaður atburður
other: "%{count} virkjaðir atburðir"
events: Atburðir
new: Ný webhook-vefkrækja
rotate_secret: Skipta um leyniteikn
secret: Leyniteikn undirritunar
status: Staða
title: Webhook-vefkrækjur
webhook: Webhook-vefkrækja
admin_mailer:
new_appeal:
actions:

@ -840,11 +840,22 @@ ko:
webhooks:
add_new: 엔드포인트 추가
delete: 삭제
description_html: "<strong>웹훅</strong>은 선택한 이벤트에 대해 마스토돈이 <strong>실시간 알림</strong>을 각자의 응용프로그램에게 보냄으로서, 당신의 응용프로그램이 <strong>자동으로 반응</strong>을 할 수 있도록 만듧니다."
disable: 비활성화
disabled: 비활성화됨
edit: 엔드포인트 수정
empty: 아직 설정한 웹훅 엔드포인트가 없습니다.
enable: 활성화
enabled: 활성화됨
enabled_events:
other: "%{count}개의 이벤트가 활성화되어 있습니다"
events: 이벤트
new: 새 웹훅
rotate_secret: 비밀키 회전
secret: 비밀키 서명
status: 상태
title: 웹훅
webhook: 웹훅
admin_mailer:
new_appeal:
actions:

@ -36,7 +36,7 @@ nl:
one: toot
other: berichten
status_count_before: Zij schreven
tagline: Gedecentraliseerd sociaal netwerk
tagline: Decentraal sociaal netwerk
terms: Gebruiksvoorwaarden
unavailable_content: Gemodereerde servers
unavailable_content_description:
@ -1306,6 +1306,9 @@ nl:
subject: Jouw archief staat klaar om te worden gedownload
title: Archief ophalen
warning:
explanation:
mark_statuses_as_sensitive: Sommige van jouw berichten zijn als gevoelig gemarkeerd door de moderatoren van %{instance}. Dit betekent dat mensen op de media in de berichten moeten klikken/tikken om deze weer te geven. Je kunt media in de toekomst ook zelf als gevoelig markeren.
sensitive: Vanaf nu worden al jouw geüploade media als gevoelig gemarkeerd en verborgen achter een waarschuwing.
subject:
disable: Jouw account %{acct} is bevroren
none: Waarschuwing voor %{acct}

@ -68,6 +68,11 @@ en:
with_dns_records: An attempt to resolve the given domain's DNS records will be made and the results will also be blocked
featured_tag:
name: 'You might want to use one of these:'
filters:
action: Chose which action to perform when a post matches the filter
actions:
hide: Completely hide the filtered content, behaving as if it did not exist
warn: Hide the filtered content behind a warning mentioning the filter's title
form_challenge:
current_password: You are entering a secure area
imports:
@ -181,6 +186,7 @@ en:
setting_use_pending_items: Slow mode
severity: Severity
sign_in_token_attempt: Security code
title: Title
type: Import type
username: Username
username_or_email: Username or Email
@ -189,6 +195,10 @@ en:
with_dns_records: Include MX records and IPs of the domain
featured_tag:
name: Hashtag
filters:
actions:
hide: Hide completely
warn: Hide with a warning
interactions:
must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow

@ -57,7 +57,7 @@ gl:
setting_hide_network: Non se mostrará no teu perfil quen te segue e a quen estás a seguir
setting_noindex: Afecta ao teu perfil público e páxinas de publicación
setting_show_application: A aplicación que estás a utilizar para enviar publicacións mostrarase na vista detallada da publicación
setting_use_blurhash: Os gradientes toman as cores da imaxe oculta pero esborranchando todos os detalles
setting_use_blurhash: Os gradientes toman as cores da imaxe oculta pero esvaecendo tódolos detalles
setting_use_pending_items: Agochar actualizacións da cronoloxía tras un click no lugar de desprazar automáticamente os comentarios
username: O teu nome de usuaria será único en %{domain}
whole_word: Se a chave ou frase de paso é só alfanumérica, só se aplicará se concorda a palabra completa
@ -177,7 +177,7 @@ gl:
setting_theme: Decorado da instancia
setting_trends: Mostrar as tendencias de hoxe
setting_unfollow_modal: Solicitar confirmación antes de deixar de seguir alguén
setting_use_blurhash: Mostrar gradientes coloridos para medios ocultos
setting_use_blurhash: Mostrar gradientes coloridos para multimedia oculto
setting_use_pending_items: Modo lento
severity: Severidade
sign_in_token_attempt: Código de seguridade

@ -91,6 +91,9 @@ is:
name: Þú getur aðeins breytt stafstöði mill há-/lágstafa, til gæmis til að gera þetta læsilegra
user:
chosen_languages: Þegar merkt er við þetta, birtast einungis færslur á völdum tungumálum á opinberum tímalínum
webhook:
events: Veldu atburði sem á að senda
url: Hvert atburðir verða sendir
labels:
account:
fields:
@ -219,6 +222,9 @@ is:
name: Myllumerki
trendable: Leyfa þessu myllumerki að birtast undir tilhneigingum
usable: Leyfa færslum að nota þetta myllumerki
webhook:
events: Virkjaðir atburðir
url: Slóð á endapunkt
'no': Nei
recommended: Mælt með
required:

@ -91,6 +91,9 @@ ko:
name: 읽기 쉽게하기 위한 글자의 대소문자만 변경할 수 있습니다.
user:
chosen_languages: 체크하면, 선택 된 언어로 작성된 게시물들만 공개 타임라인에 보여집니다
webhook:
events: 전송할 이벤트를 선택하세요
url: 이벤트가 어디로 전송될 지
labels:
account:
fields:
@ -219,6 +222,9 @@ ko:
name: 해시태그
trendable: 이 해시태그가 유행에 보여지도록 허용
usable: 이 해시태그를 게시물에 사용 가능하도록 허용
webhook:
events: 활성화된 이벤트
url: 엔드포인트 URL
'no': 아니오
recommended: 추천함
required:

@ -201,6 +201,8 @@ nl:
mention: Wanneer iemand jou heeft vermeld
pending_account: Wanneer een nieuw account moet worden beoordeeld
reblog: Wanneer iemand jouw bericht heeft geboost
report: Nieuwe rapportage is ingediend
trending_tag: Nieuwe trend vereist beoordeling
rule:
text: Regel
tag:
@ -208,6 +210,8 @@ nl:
name: Hashtag
trendable: Toestaan dat deze hashtag onder trends te zien valt
usable: Toestaan dat deze hashtag in berichten gebruikt mag worden
webhook:
url: Eindpunt URL
'no': Nee
recommended: Aanbevolen
required:

@ -88,6 +88,8 @@ th:
name: คุณสามารถเปลี่ยนได้เฉพาะตัวพิมพ์ใหญ่เล็กของตัวอักษรเท่านั้น ตัวอย่างเช่น เพื่อทำให้ตัวอักษรอ่านได้ง่ายขึ้น
user:
chosen_languages: เมื่อกาเครื่องหมาย จะแสดงเฉพาะโพสต์ในภาษาที่เลือกในเส้นเวลาสาธารณะเท่านั้น
webhook:
events: เลือกเหตุการณ์ที่จะส่ง
labels:
account:
fields:
@ -214,6 +216,8 @@ th:
name: แฮชแท็ก
trendable: อนุญาตให้แฮชแท็กนี้ปรากฏภายใต้แนวโน้ม
usable: อนุญาตให้โพสต์ใช้แฮชแท็กนี้
webhook:
url: URL ปลายทาง
'no': ไม่
recommended: แนะนำ
required:

@ -34,6 +34,7 @@ th:
status_count_after:
other: โพสต์
status_count_before: ผู้เผยแพร่
tagline: เครือข่ายสังคมแบบกระจายศูนย์
terms: เงื่อนไขการให้บริการ
unavailable_content: เซิร์ฟเวอร์ที่มีการควบคุม
unavailable_content_description:
@ -782,6 +783,16 @@ th:
edit_preset: แก้ไขคำเตือนที่ตั้งไว้ล่วงหน้า
empty: คุณยังไม่ได้กำหนดคำเตือนที่ตั้งไว้ล่วงหน้าใด ๆ
title: จัดการคำเตือนที่ตั้งไว้ล่วงหน้า
webhooks:
add_new: เพิ่มปลายทาง
delete: ลบ
disable: ปิดใช้งาน
disabled: ปิดใช้งานอยู่
edit: แก้ไขปลายทาง
enable: เปิดใช้งาน
enabled: ใช้งานอยู่
events: เหตุการณ์
status: สถานะ
admin_mailer:
new_appeal:
actions:
@ -1248,6 +1259,10 @@ th:
reports:
errors:
invalid_rules: ไม่ได้อ้างอิงกฎที่ถูกต้อง
rss:
content_warning: 'คำเตือนเนื้อหา:'
descriptions:
account: โพสต์สาธารณะจาก @%{acct}
scheduled_statuses:
too_soon: วันที่ตามกำหนดการต้องอยู่ในอนาคต
sessions:

@ -862,6 +862,9 @@ tr:
empty: Henüz yapılandırılmış bir web kancanız yok.
enable: Etkinleştir
enabled: Etkin
enabled_events:
one: 1 aktif etkinlik
other: "%{count} aktif etkinlik"
events: Olaylar
new: Yeni web kancası
rotate_secret: Gizi döndür

@ -646,6 +646,7 @@ uk:
placeholder: Опишіть, які дії були виконані, або інші зміни, що стосуються справи...
title: Примітки
notes_description_html: Переглядайте та залишайте примітки для інших модераторів та для себе на майбутнє
quick_actions_description_html: 'Виберіть швидку дію або гортайте вниз, щоб побачити матеріал, на який надійшла скарга:'
remote_user_placeholder: віддалений користувач із %{instance}
reopen: Перевідкрити скаргу
report: 'Скарга #%{id}'
@ -896,6 +897,7 @@ uk:
sensitive: щоб позначати їхній обліковий запис делікатним
silence: щоб обмежити їхній обліковий запис
suspend: щоб призупинити їхній обліковий запис
body: "%{target} оскаржує модерацію %{action_taken_by} від %{date}, яка була %{type}. Вони написали:"
next_steps: Ви можете схвалити апеляцію, щоб скасувати рішення про модерацію або проігнорувати її.
subject: "%{username} апелює до рішення про модерацію на %{instance}"
new_pending_account:
@ -991,6 +993,7 @@ uk:
functional: Ваш обліковий запис повністю робочий.
pending: Ваша заява очікує на розгляд нашим персоналом. Це може зайняти деякий час. Ви отримаєте електронний лист, якщо ваша заява буде схвалена.
redirecting_to: Ваш обліковий запис наразі неактивний, тому що він перенаправлений до %{acct}.
view_strikes: Переглянути попередні попередження вашому обліковому запису
too_fast: Форму подано занадто швидко, спробуйте ще раз.
trouble_logging_in: Проблема під час входу?
use_security_key: Використовувати ключ безпеки
@ -1058,6 +1061,7 @@ uk:
strikes:
action_taken: Дію виконано
appeal: Апеляція
appeal_approved: Це попередження було успішно оскаржене і більше не дійсне
appeal_rejected: Апеляцію було відхилено
appeal_submitted_at: Апеляцію надіслано
appealed_msg: Вашу апеляцію було надіслано. Якщо її погодять, вам буде повідомлено про це.
@ -1392,6 +1396,9 @@ uk:
invalid_rules: не посилається на чинні правила
rss:
content_warning: 'Попередження про матеріали:'
descriptions:
account: Загальнодоступні дописи від @%{acct}
tag: 'Загальнодоступні дописи позначені #%{hashtag}'
scheduled_statuses:
over_daily_limit: Ви перевищили ліміт в %{limit} запланованих дмухів на сьогодні
over_total_limit: Ви перевищили ліміт в %{limit} запланованих дмухів
@ -1562,6 +1569,9 @@ uk:
pinned: Закріплений пост
reblogged: передмухнув(-ла)
sensitive_content: Дражливий зміст
strikes:
errors:
too_late: Запізно оскаржувати це попередження
tags:
does_not_match_previous_name: не збігається з попереднім ім'ям
terms:
@ -1593,9 +1603,11 @@ uk:
user_mailer:
appeal_approved:
action: Перейти у ваш обліковий запис
explanation: Оскарження попередження вашому обліковому запису %{strike_date}, яке ви надіслали %{appeal_date} було схвалено. Ваш обліковий запис знову вважається добропорядним.
subject: Вашу апеляцію від %{date} було схвалено
title: Апеляцію схвалено
appeal_rejected:
explanation: Оскарження попередження вашому обліковому запису %{strike_date}, яке ви надіслали %{appeal_date} було відхилено.
subject: Вашу апеляцію від %{date} було відхилено
title: Апеляцію відхилено
backup_ready:
@ -1611,12 +1623,14 @@ uk:
title: Новий вхід
warning:
appeal: Подати апеляцію
appeal_description: Якщо ви вважаєте, що це помилка, ви можете надіслати оскаржити дії персоналу %{instance}.
categories:
spam: Спам
violation: Вміст порушує такі правила спільноти
explanation:
delete_statuses: Деякі з ваших дописів порушили одне або кілька правил спільноти, і модератори %{instance} видалили їх.
disable: Ви можете більше не використовувати свій обліковий запис, але ваш профіль та інші дані залишаються недоторканими. Ви можете надіслати запит на створення резервної копії ваших даних, змінити налаштування облікового запису або видалити свій обліковий запис.
mark_statuses_as_sensitive: Деякі з ваших дописів модератори %{instance} позначили делікатними. Це означає, що людям потрібно буде торкнутися медіа у дописах перед тим, як буде показано попередній перегляд. Ви можете самостійно позначити медіа делікатним, коли розміщуватимете його в майбутньому.
sensitive: Відтепер усі ваші завантажені медіафайли будуть позначені делікатними й приховані за попередженням.
silence: Ви й надалі можете користуватися своїм обліковим записом, але ваші дописи на цьому сервері бачитимуть лише ті люди, які вже стежать за вами, а вас може бути виключено з різних можливостей виявлення. Проте, інші можуть почати стежити за вами вручну.
suspend: Ви більше не можете користуватися своїм обліковим записом, а ваші інші дані більше недоступні. Ви досі можете увійти, щоб надіслати запит на отримання резервної копії своїх даних до повного видалення впродовж приблизно 30 днів, але ми збережемо деякі основні дані, щоб унеможливити ухилення вами від призупинення.
@ -1625,7 +1639,9 @@ uk:
subject:
delete_statuses: Ваші дописи на %{acct} були вилучені
disable: Ваш обліковий запис %{acct} було заморожено
mark_statuses_as_sensitive: Ваші дописи на %{acct} позначені делікатними
none: Попередження для %{acct}
sensitive: Ваші дописи на %{acct} відтепер будуть позначені делікатними
silence: Ваш обліковий запис %{acct} було обмежено
suspend: Ваш обліковий запис %{acct} було призупинено
title:

@ -473,10 +473,16 @@ Rails.application.routes.draw do
resources :bookmarks, only: [:index]
resources :reports, only: [:create]
resources :trends, only: [:index], controller: 'trends/tags'
resources :filters, only: [:index, :create, :show, :update, :destroy]
resources :filters, only: [:index, :create, :show, :update, :destroy] do
resources :keywords, only: [:index, :create], controller: 'filters/keywords'
end
resources :endorsements, only: [:index]
resources :markers, only: [:index, :create]
namespace :filters do
resources :keywords, only: [:show, :update, :destroy]
end
namespace :apps do
get :verify_credentials, to: 'credentials#show'
end
@ -594,6 +600,7 @@ Rails.application.routes.draw do
end
end
resources :domain_allows, only: [:index, :show, :create, :destroy]
resources :domain_blocks, only: [:index, :show, :update, :create, :destroy]
namespace :trends do
@ -612,6 +619,7 @@ Rails.application.routes.draw do
resources :media, only: [:create]
get '/search', to: 'search#index', as: :search
resources :suggestions, only: [:index]
resources :filters, only: [:index, :create, :show, :update, :destroy]
namespace :admin do
resources :accounts, only: [:index]

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateCustomFilterKeywords < ActiveRecord::Migration[6.1]
def change
create_table :custom_filter_keywords do |t|
t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false
t.text :keyword, null: false, default: ''
t.boolean :whole_word, null: false, default: true
t.timestamps
end
end
end

@ -0,0 +1,34 @@
# frozen_string_literal: true
class MigrateCustomFilters < ActiveRecord::Migration[6.1]
def up
# Preserve IDs as much as possible to not confuse existing clients.
# As long as this migration is irreversible, we do not have to deal with conflicts.
safety_assured do
execute <<-SQL.squish
INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at)
SELECT id, id, phrase, whole_word, created_at, updated_at
FROM custom_filters
SQL
end
end
def down
# Copy back changes from custom filters guaranteed to be from the old API
safety_assured do
execute <<-SQL.squish
UPDATE custom_filters
SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word
FROM custom_filter_keywords
WHERE custom_filters.id = custom_filter_keywords.id AND custom_filters.id = custom_filter_keywords.custom_filter_id
SQL
end
# Drop every keyword as we can't safely provide a 1:1 mapping
safety_assured do
execute <<-SQL.squish
TRUNCATE custom_filter_keywords RESTART IDENTITY
SQL
end
end
end

@ -0,0 +1,20 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddActionToCustomFilters < ActiveRecord::Migration[6.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured do
add_column_with_default :custom_filters, :action, :integer, allow_null: false, default: 0
execute 'UPDATE custom_filters SET action = 1 WHERE irreversible IS TRUE'
end
end
def down
execute 'UPDATE custom_filters SET irreversible = (action = 1)'
remove_column :custom_filters, :action
end
end

@ -0,0 +1,20 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class RemoveWholeWordFromCustomFilters < ActiveRecord::Migration[6.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured do
remove_column :custom_filters, :whole_word
end
end
def down
safety_assured do
add_column_with_default :custom_filters, :whole_word, :boolean, default: true, allow_null: false
end
end
end

@ -0,0 +1,20 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class RemoveIrreversibleFromCustomFilters < ActiveRecord::Migration[6.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured do
remove_column :custom_filters, :irreversible
end
end
def down
safety_assured do
add_column_with_default :custom_filters, :irreversible, :boolean, allow_null: false, default: false
end
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_06_06_044941) do
ActiveRecord::Schema.define(version: 2022_06_13_110903) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -339,15 +339,23 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do
t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
end
create_table "custom_filter_keywords", force: :cascade do |t|
t.bigint "custom_filter_id", null: false
t.text "keyword", default: "", null: false
t.boolean "whole_word", default: true, null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["custom_filter_id"], name: "index_custom_filter_keywords_on_custom_filter_id"
end
create_table "custom_filters", force: :cascade do |t|
t.bigint "account_id"
t.datetime "expires_at"
t.text "phrase", default: "", null: false
t.string "context", default: [], null: false, array: true
t.boolean "irreversible", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "whole_word", default: true, null: false
t.integer "action", default: 0, null: false
t.index ["account_id"], name: "index_custom_filters_on_account_id"
end
@ -1085,6 +1093,7 @@ ActiveRecord::Schema.define(version: 2022_06_06_044941) do
add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", on_delete: :cascade
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
add_foreign_key "custom_filter_keywords", "custom_filters", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "devices", "accounts", on_delete: :cascade
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade

@ -38,10 +38,26 @@ namespace :tests do
puts 'Instance actor does not have a private key'
exit(1)
end
unless Account.find_by(username: 'user', domain: nil).custom_filters.map { |filter| filter.keywords.pluck(:keyword) } == [['test'], ['take']]
puts 'CustomFilterKeyword records not created as expected'
exit(1)
end
end
desc 'Populate the database with test data for 2.4.3'
task populate_v2_4_3: :environment do # rubocop:disable Naming/VariableNumber
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO "custom_filters"
(id, account_id, phrase, context, whole_word, irreversible, created_at, updated_at)
VALUES
(1, 2, 'test', '{ "home", "public" }', true, true, now(), now()),
(2, 2, 'take', '{ "home" }', false, false, now(), now());
SQL
end
desc 'Populate the database with test data for 2.4.0'
task populate_v2_4: :environment do
task populate_v2_4: :environment do # rubocop:disable Naming/VariableNumber
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO "settings"
(id, thing_type, thing_id, var, value, created_at, updated_at)

@ -46,6 +46,7 @@
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"blurhash": "^1.1.5",
"classnames": "^2.3.1",
"cocoon-js-vanilla": "^1.2.0",
"color-blend": "^3.0.1",
"compression-webpack-plugin": "^6.1.1",
"cross-env": "^7.0.3",
@ -73,6 +74,7 @@
"intl-relativeformat": "^6.4.3",
"is-nan": "^1.3.2",
"js-yaml": "^4.1.0",
"jsdom": "^20.0.0",
"lodash": "^4.17.21",
"mark-loader": "^0.1.6",
"marky": "^1.2.4",

@ -0,0 +1,118 @@
require 'rails_helper'
RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
render_views
let(:role) { 'admin' }
let(:user) { Fabricate(:user, role: role) }
let(:scopes) { 'admin:read admin:write' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
shared_examples 'forbidden for wrong scope' do |wrong_scope|
let(:scopes) { wrong_scope }
it 'returns http forbidden' do
expect(response).to have_http_status(403)
end
end
shared_examples 'forbidden for wrong role' do |wrong_role|
let(:role) { wrong_role }
it 'returns http forbidden' do
expect(response).to have_http_status(403)
end
end
describe 'GET #index' do
let!(:domain_allow) { Fabricate(:domain_allow) }
before do
get :index
end
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
it_behaves_like 'forbidden for wrong role', 'user'
it_behaves_like 'forbidden for wrong role', 'moderator'
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns the expected domain allows' do
json = body_as_json
expect(json.length).to eq 1
expect(json[0][:id].to_i).to eq domain_allow.id
end
end
describe 'GET #show' do
let!(:domain_allow) { Fabricate(:domain_allow) }
before do
get :show, params: { id: domain_allow.id }
end
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
it_behaves_like 'forbidden for wrong role', 'user'
it_behaves_like 'forbidden for wrong role', 'moderator'
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns expected domain name' do
json = body_as_json
expect(json[:domain]).to eq domain_allow.domain
end
end
describe 'DELETE #destroy' do
let!(:domain_allow) { Fabricate(:domain_allow) }
before do
delete :destroy, params: { id: domain_allow.id }
end
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
it_behaves_like 'forbidden for wrong role', 'user'
it_behaves_like 'forbidden for wrong role', 'moderator'
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'deletes the block' do
expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil
end
end
describe 'POST #create' do
let!(:domain_allow) { Fabricate(:domain_allow, domain: 'example.com') }
before do
post :create, params: { domain: 'foo.bar.com' }
end
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
it_behaves_like 'forbidden for wrong role', 'user'
it_behaves_like 'forbidden for wrong role', 'moderator'
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns expected domain name' do
json = body_as_json
expect(json[:domain]).to eq 'foo.bar.com'
end
it 'creates a domain block' do
expect(DomainAllow.find_by(domain: 'foo.bar.com')).to_not be_nil
end
end
end

@ -0,0 +1,142 @@
require 'rails_helper'
RSpec.describe Api::V1::Filters::KeywordsController, type: :controller do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:other_user) { Fabricate(:user) }
let(:other_filter) { Fabricate(:custom_filter, account: other_user.account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:scopes) { 'read:filters' }
let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
it 'returns http success' do
get :index, params: { filter_id: filter.id }
expect(response).to have_http_status(200)
end
context "when trying to access another's user filters" do
it 'returns http not found' do
get :index, params: { filter_id: other_filter.id }
expect(response).to have_http_status(404)
end
end
end
describe 'POST #create' do
let(:scopes) { 'write:filters' }
let(:filter_id) { filter.id }
before do
post :create, params: { filter_id: filter_id, keyword: 'magic', whole_word: false }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns a keyword' do
json = body_as_json
expect(json[:keyword]).to eq 'magic'
expect(json[:whole_word]).to eq false
end
it 'creates a keyword' do
filter = user.account.custom_filters.first
expect(filter).to_not be_nil
expect(filter.keywords.pluck(:keyword)).to eq ['magic']
end
context "when trying to add to another another's user filters" do
let(:filter_id) { other_filter.id }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'GET #show' do
let(:scopes) { 'read:filters' }
let(:keyword) { Fabricate(:custom_filter_keyword, keyword: 'foo', whole_word: false, custom_filter: filter) }
before do
get :show, params: { id: keyword.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns expected data' do
json = body_as_json
expect(json[:keyword]).to eq 'foo'
expect(json[:whole_word]).to eq false
end
context "when trying to access another user's filter keyword" do
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'PUT #update' do
let(:scopes) { 'write:filters' }
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
before do
get :update, params: { id: keyword.id, keyword: 'updated' }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the keyword' do
expect(keyword.reload.keyword).to eq 'updated'
end
context "when trying to update another user's filter keyword" do
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
describe 'DELETE #destroy' do
let(:scopes) { 'write:filters' }
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
before do
delete :destroy, params: { id: keyword.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'removes the filter' do
expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound
end
context "when trying to update another user's filter keyword" do
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: other_filter) }
it 'returns http not found' do
expect(response).to have_http_status(404)
end
end
end
end

@ -34,7 +34,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
it 'creates a filter' do
filter = user.account.custom_filters.first
expect(filter).to_not be_nil
expect(filter.phrase).to eq 'magic'
expect(filter.keywords.pluck(:keyword)).to eq ['magic']
expect(filter.context).to eq %w(home)
expect(filter.irreversible?).to be true
expect(filter.expires_at).to be_nil
@ -44,9 +44,10 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
describe 'GET #show' do
let(:scopes) { 'read:filters' }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
it 'returns http success' do
get :show, params: { id: filter.id }
get :show, params: { id: keyword.id }
expect(response).to have_http_status(200)
end
end
@ -54,9 +55,10 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
describe 'PUT #update' do
let(:scopes) { 'write:filters' }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
before do
put :update, params: { id: filter.id, phrase: 'updated' }
put :update, params: { id: keyword.id, phrase: 'updated' }
end
it 'returns http success' do
@ -64,16 +66,17 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
end
it 'updates the filter' do
expect(filter.reload.phrase).to eq 'updated'
expect(keyword.reload.phrase).to eq 'updated'
end
end
describe 'DELETE #destroy' do
let(:scopes) { 'write:filters' }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
let(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
before do
delete :destroy, params: { id: filter.id }
delete :destroy, params: { id: keyword.id }
end
it 'returns http success' do
@ -81,7 +84,7 @@ RSpec.describe Api::V1::FiltersController, type: :controller do
end
it 'removes the filter' do
expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound
expect { keyword.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
end

@ -20,6 +20,58 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
get :show, params: { id: status.id }
expect(response).to have_http_status(200)
end
context 'when post includes filtered terms' do
let(:status) { Fabricate(:status, text: 'this toot is about that banned word') }
before do
user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'returns http success' do
get :show, params: { id: status.id }
expect(response).to have_http_status(200)
end
it 'returns filter information' do
get :show, params: { id: status.id }
json = body_as_json
expect(json[:filtered][0]).to include({
filter: a_hash_including({
id: user.account.custom_filters.first.id.to_s,
title: 'filter1',
filter_action: 'hide',
}),
keyword_matches: ['banned'],
})
end
end
context 'when reblog includes filtered terms' do
let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) }
before do
user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'returns http success' do
get :show, params: { id: status.id }
expect(response).to have_http_status(200)
end
it 'returns filter information' do
get :show, params: { id: status.id }
json = body_as_json
expect(json[:reblog][:filtered][0]).to include({
filter: a_hash_including({
id: user.account.custom_filters.first.id.to_s,
title: 'filter1',
filter_action: 'hide',
}),
keyword_matches: ['banned'],
})
end
end
end
describe 'GET #context' do

@ -0,0 +1,121 @@
require 'rails_helper'
RSpec.describe Api::V2::FiltersController, type: :controller do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:scopes) { 'read:filters' }
let!(:filter) { Fabricate(:custom_filter, account: user.account) }
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
describe 'POST #create' do
let(:scopes) { 'write:filters' }
before do
post :create, params: { title: 'magic', context: %w(home), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns a filter with keywords' do
json = body_as_json
expect(json[:title]).to eq 'magic'
expect(json[:filter_action]).to eq 'hide'
expect(json[:context]).to eq ['home']
expect(json[:keywords].map { |keyword| keyword.slice(:keyword, :whole_word) }).to eq [{ keyword: 'magic', whole_word: true }]
end
it 'creates a filter' do
filter = user.account.custom_filters.first
expect(filter).to_not be_nil
expect(filter.keywords.pluck(:keyword)).to eq ['magic']
expect(filter.context).to eq %w(home)
expect(filter.irreversible?).to be true
expect(filter.expires_at).to be_nil
end
end
describe 'GET #show' do
let(:scopes) { 'read:filters' }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
it 'returns http success' do
get :show, params: { id: filter.id }
expect(response).to have_http_status(200)
end
end
describe 'PUT #update' do
let(:scopes) { 'write:filters' }
let!(:filter) { Fabricate(:custom_filter, account: user.account) }
let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
context 'updating filter parameters' do
before do
put :update, params: { id: filter.id, title: 'updated', context: %w(home public) }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the filter title' do
expect(filter.reload.title).to eq 'updated'
end
it 'updates the filter context' do
expect(filter.reload.context).to eq %w(home public)
end
end
context 'updating keywords in bulk' do
before do
allow(redis).to receive_messages(publish: nil)
put :update, params: { id: filter.id, keywords_attributes: [{ id: keyword.id, keyword: 'updated' }] }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'updates the keyword' do
expect(keyword.reload.keyword).to eq 'updated'
end
it 'sends exactly one filters_changed event' do
expect(redis).to have_received(:publish).with("timeline:#{user.account.id}", Oj.dump(event: :filters_changed)).once
end
end
end
describe 'DELETE #destroy' do
let(:scopes) { 'write:filters' }
let(:filter) { Fabricate(:custom_filter, account: user.account) }
before do
delete :destroy, params: { id: filter.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'removes the filter' do
expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
end

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

Loading…
Cancel
Save