Merge branch 'kb_development' into kb_migration

This commit is contained in:
KMY 2023-04-25 10:32:16 +09:00
commit 9b032185b8
59 changed files with 1441 additions and 28 deletions

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
class AntennasController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_antenna, only: [:edit, :update, :destroy]
before_action :set_lists, only: [:new, :edit]
before_action :set_body_classes
before_action :set_cache_headers
def index
@antennas = current_account.antennas.includes(:antenna_domains).includes(:antenna_tags).includes(:antenna_accounts)
end
def new
@antenna = current_account.antennas.build
@antenna.antenna_domains.build
@antenna.antenna_tags.build
@antenna.antenna_accounts.build
end
def create
@antenna = current_account.antennas.build(thin_resource_params)
saved = @antenna.save
saved = @antenna.update(resource_params) if saved
if saved
redirect_to antennas_path
else
render action: :new
end
end
def edit; end
def update
if @antenna.update(resource_params)
redirect_to antennas_path
else
render action: :edit
end
end
def destroy
@antenna.destroy
redirect_to antennas_path
end
private
def set_antenna
@antenna = current_account.antennas.find(params[:id])
end
def set_lists
@lists = current_account.owned_lists
end
def resource_params
params.require(:antenna).permit(:title, :list, :available, :expires_in, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw)
end
def thin_resource_params
params.require(:antenna).permit(:title, :list)
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Accounts::AntennasController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
before_action :require_user!
before_action :set_account
def index
@antennas = @account.suspended? ? [] : @account.joined_antennas.where(account: current_account)
render json: @antennas, each_serializer: REST::AntennaSerializer
end
private
def set_account
@account = Account.find(params[:account_id])
end
end

View file

@ -30,6 +30,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
:bot,
:discoverable,
:searchability,
:dissubscribable,
:hide_collections,
fields_attributes: [:name, :value]
)

View file

@ -0,0 +1,82 @@
# frozen_string_literal: true
class Api::V1::Antennas::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_antenna
after_action :insert_pagination_headers, only: :show
def create
ApplicationRecord.transaction do
antenna_accounts.each do |account|
@antenna.antenna_accounts.create!(account: account, exclude: false)
@antenna.update!(any_accounts: false)
end
end
render_empty
end
def destroy
AntennaAccount.where(antenna: @antenna, account_id: account_ids).destroy_all
@antenna.update!(any_accounts: true) if !@antenna.antenna_accounts.where(exclude: false).any?
render_empty
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:antenna_id])
end
def antenna_accounts
Account.find(account_ids)
end
def account_ids
Array(resource_params[:account_ids])
end
def resource_params
params.permit(account_ids: [])
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
return if unlimited?
api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end
def pagination_max_id
@accounts.last.id
end
def pagination_since_id
@accounts.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class Api::V1::AntennasController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user!
before_action :set_antenna, except: [:index]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
end
def index
@antennas = Antenna.where(account: current_account).all
render json: @antennas, each_serializer: REST::AntennaSerializer
end
def show
render json: @antenna, serializer: REST::AntennaSerializer
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:id])
end
end

View file

@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController
private
def account_params
params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :searchability, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value])
params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :my_actor_type, :searchability, :dissubscribable, :group_allow_private_message, :discoverable, :hide_collections, fields_attributes: [:name, :value])
end
def set_account

View file

@ -23,6 +23,7 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => "fedibird:emojiReactions", '@type' => '@id' } },
searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => "fedibird:searchableBy", '@type' => '@id' } },
subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => "kmyblue:subscribableBy", '@type' => '@id' } },
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
}.freeze

View file

@ -0,0 +1,179 @@
import api from '../api';
import { importFetchedAccounts } from './importer';
export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST';
export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS';
export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL';
export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST';
export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS';
export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL';
export const ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST';
export const ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS';
export const ANTENNA_EDITOR_ADD_ACCOUNT_FAIL = 'ANTENNA_EDITOR_ADD_ACCOUNT_FAIL';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL';
export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST';
export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS';
export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL';
export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET';
export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP';
export const fetchAntennas = () => (dispatch, getState) => {
dispatch(fetchAntennasRequest());
api(getState).get('/api/v1/antennas')
.then(({ data }) => dispatch(fetchAntennasSuccess(data)))
.catch(err => dispatch(fetchAntennasFail(err)));
};
export const fetchAntennasRequest = () => ({
type: ANTENNAS_FETCH_REQUEST,
});
export const fetchAntennasSuccess = antennas => ({
type: ANTENNAS_FETCH_SUCCESS,
antennas,
});
export const fetchAntennasFail = error => ({
type: ANTENNAS_FETCH_FAIL,
error,
});
export const fetchAntennaAccounts = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaAccountsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAntennaAccountsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaAccountsFail(antennaId, err)));
};
export const fetchAntennaAccountsRequest = id => ({
type: ANTENNA_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchAntennaAccountsSuccess = (id, accounts, next) => ({
type: ANTENNA_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchAntennaAccountsFail = (id, error) => ({
type: ANTENNA_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const addAccountToAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(addAccountToAntennaRequest(antennaId, accountId));
api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addAccountToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addAccountToAntennaFail(antennaId, accountId, err)));
};
export const addAccountToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST,
antennaId,
accountId,
});
export const addAccountToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS,
antennaId,
accountId,
});
export const addAntennaToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_FAIL,
antennaId,
accountId,
error,
});
export const removeAccountFromAntennaEditor = accountId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const removeAccountFromAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(removeAccountFromAntennaRequest(antennaId, accountId));
api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeAccountFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeAccountFromAntennaFail(antennaId, accountId, err)));
};
export const removeAccountFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST,
antennaId,
accountId,
});
export const removeAccountFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS,
antennaId,
accountId,
});
export const removeAccountFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL,
antennaId,
accountId,
error,
});
export const addToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addAccountToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const fetchAccountAntennas = accountId => (dispatch, getState) => {
dispatch(fetchAccountAntennasRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/antennas`)
.then(({ data }) => dispatch(fetchAccountAntennasSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountAntennasFail(accountId, err)));
};
export const fetchAccountAntennasRequest = id => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
id,
});
export const fetchAccountAntennasSuccess = (id, antennas) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
id,
antennas,
});
export const fetchAccountAntennasFail = (id, err) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
id,
err,
});
export const resetAntennaAdder = () => ({
type: ANTENNA_ADDER_RESET,
});
export const setupAntennaAdder = accountId => (dispatch, getState) => {
dispatch({
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchAccountAntennas(accountId));
};

View file

@ -70,10 +70,6 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.emoji_reactions = normalizeEmojiReactions(status.emoji_reactions);
}
if (status.media_attachments_ex) {
normalStatus.media_attachments = status.media_attachments.concat(status.media_attachments_ex);
}
if (!status.visibility_ex) {
normalStatus.visibility_ex = status.visibility;
}

View file

@ -53,6 +53,7 @@ const messages = defineMessages({
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
add_or_remove_from_antenna: { id: 'account.add_or_remove_from_antenna', defaultMessage: 'Add or Remove from antennas' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
@ -97,6 +98,7 @@ class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onAddToAntenna: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
@ -262,8 +264,9 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_antenna), action: this.props.onAddToAntenna });
menu.push(null);
if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });

View file

@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onAddToAntenna: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
@ -91,6 +92,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onAddToList(this.props.account);
};
handleAddToAntenna = () => {
this.props.onAddToAntenna(this.props.account);
};
handleEditAccountNote = () => {
this.props.onEditAccountNote(this.props.account);
};
@ -133,6 +138,7 @@ export default class Header extends ImmutablePureComponent {
onUnblockDomain={this.handleUnblockDomain}
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
onAddToAntenna={this.handleAddToAntenna}
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}

View file

@ -146,6 +146,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onAddToAntenna (account) {
dispatch(openModal('ANTENNA_ADDER', {
accountId: account.get('id'),
}));
},
onChangeLanguages (account) {
dispatch(openModal('SUBSCRIBED_LANGUAGES', {
accountId: account.get('id'),

View file

@ -0,0 +1,43 @@
import React from 'react';
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import { injectIntl } from 'react-intl';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
@injectIntl
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/antennas';
import Icon from 'mastodon/components/icon';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const MapStateToProps = (state, { antennaId, added }) => ({
antenna: state.get('antennas').get(antennaId),
added: typeof added === 'undefined' ? state.getIn(['antennaAdder', 'antennas', 'items']).includes(antennaId) : added,
});
const mapDispatchToProps = (dispatch, { antennaId }) => ({
onRemove: () => dispatch(removeFromAntennaAdder(antennaId)),
onAdd: () => dispatch(addToAntennaAdder(antennaId)),
});
export default @connect(MapStateToProps, mapDispatchToProps)
@injectIntl
class Antenna extends ImmutablePureComponent {
static propTypes = {
antenna: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { antenna, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='wifi' className='column-link__icon' fixedWidth />
{antenna.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas';
import { createSelector } from 'reselect';
import Antenna from './components/antenna';
import Account from './components/account';
// hack
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennaIds: getOrderedAntennas(state).map(antenna=>antenna.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupAntennaAdder(accountId)),
onReset: () => dispatch(resetAntennaAdder()),
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class AntennaAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
antennaIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, antennaIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<div className='list-adder__lists'>
{antennaIds.map(AntennaId => <Antenna key={AntennaId} antennaId={AntennaId} />)}
</div>
</div>
);
}
}

View file

@ -20,6 +20,7 @@ import {
EmbedModal,
ListEditor,
ListAdder,
AntennaAdder,
CompareHistoryModal,
FilterModal,
InteractionModal,
@ -43,6 +44,7 @@ const MODAL_COMPONENTS = {
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,

View file

@ -146,6 +146,10 @@ export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
}
export function AntennaAdder () {
return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder');
}
export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
}

View file

@ -459,11 +459,11 @@
"privacy.direct.short": "指定された相手のみ",
"privacy.private.long": "フォロワーのみ閲覧可",
"privacy.private.short": "フォロワーのみ",
"privacy.public.long": "誰でも閲覧可、全てのTL",
"privacy.public.long": "誰でも閲覧可、ホーム+ローカル+連合TL",
"privacy.public.short": "公開",
"privacy.public_unlisted.long": "誰でも閲覧可、ローカル+ホームTL",
"privacy.public_unlisted.long": "誰でも閲覧可、ホーム+ローカルTL",
"privacy.public_unlisted.short": "ローカル公開",
"privacy.unlisted.long": "誰でも閲覧可、ホームTL",
"privacy.unlisted.long": "誰でも閲覧可、ホームTL",
"privacy.unlisted.short": "未収載",
"privacy_policy.last_updated": "{date}に更新",
"privacy_policy.title": "プライバシーポリシー",

View file

@ -0,0 +1,47 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
ANTENNA_ADDER_RESET,
ANTENNA_ADDER_SETUP,
ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS,
ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS,
} from '../actions/antennas';
const initialState = ImmutableMap({
accountId: null,
antennas: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
});
export default function antennaAdderReducer(state = initialState, action) {
switch(action.type) {
case ANTENNA_ADDER_RESET:
return initialState;
case ANTENNA_ADDER_SETUP:
return state.withMutations(map => {
map.set('accountId', action.account.get('id'));
});
case ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST:
return state.setIn(['antennas', 'isLoading'], true);
case ANTENNA_ADDER_ANTENNAS_FETCH_FAIL:
return state.setIn(['antennas', 'isLoading'], false);
case ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS:
return state.update('antennas', antennas => antennas.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.antennas.map(item => item.id)));
}));
case ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId));
case ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId));
default:
return state;
}
}

View file

@ -0,0 +1,36 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
ANTENNA_ACCOUNTS_FETCH_REQUEST,
ANTENNA_ACCOUNTS_FETCH_SUCCESS,
ANTENNA_ACCOUNTS_FETCH_FAIL,
} from '../actions/antennas';
const initialState = ImmutableMap({
antennaId: null,
isSubmitting: false,
isChanged: false,
title: '',
accounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
});
export default function antennaEditorReducer(state = initialState, action) {
switch(action.type) {
case ANTENNA_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true);
case ANTENNA_ACCOUNTS_FETCH_FAIL:
return state.setIn(['accounts', 'isLoading'], false);
case ANTENNA_ACCOUNTS_FETCH_SUCCESS:
return state.update('accounts', accounts => accounts.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
default:
return state;
}
}

View file

@ -0,0 +1,25 @@
import {
ANTENNAS_FETCH_SUCCESS,
} from '../actions/antennas';
import { Map as ImmutableMap, fromJS } from 'immutable';
const initialState = ImmutableMap();
const normalizeAntenna = (state, antenna) => state.set(antenna.id, fromJS(antenna));
const normalizeAntennas = (state, antennas) => {
antennas.forEach(antenna => {
state = normalizeAntenna(state, antenna);
});
return state;
};
export default function antennas(state = initialState, action) {
switch(action.type) {
case ANTENNAS_FETCH_SUCCESS:
return normalizeAntennas(state, action.antennas);
default:
return state;
}
}

View file

@ -28,6 +28,9 @@ import custom_emojis from './custom_emojis';
import lists from './lists';
import listEditor from './list_editor';
import listAdder from './list_adder';
import antennas from './antennas';
import antennaEditor from './antenna_editor';
import antennaAdder from './antenna_adder';
import filters from './filters';
import conversations from './conversations';
import suggestions from './suggestions';
@ -74,6 +77,9 @@ const reducers = {
lists,
listEditor,
listAdder,
antennas,
antennaEditor,
antennaAdder,
filters,
conversations,
suggestions,

View file

@ -1067,6 +1067,13 @@ a.name-tag,
margin-bottom: 10px;
}
.listname {
color: $dark-text-color;
font-weight: bold;
font-size: 14px;
margin-left: 16px;
}
.expiration {
font-size: 13px;
}

View file

@ -449,10 +449,26 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/.freeze
def searchability
searchability = searchability_from_audience
return nil if searchability.nil?
if searchability.nil?
note = @account&.note
return nil unless note.present?
searchability_bio = note.scan(SCAN_SEARCHABILITY_RE).first
return nil unless searchability_bio
searchability = searchability_bio[0]
return nil if searchability.nil?
searchability = :public if searchability == 'public'
searchability = :unlisted if searchability == 'followers'
searchability = :direct if searchability == 'private'
searchability = :private if searchability == 'reactors'
end
visibility = visibility_from_audience_with_silence

View file

@ -186,9 +186,13 @@ class ActivityPub::TagManager
nil
end
def subscribable_by(account)
account.dissubscribable ? [] : [COLLECTIONS[:public]]
end
def searchable_by(status)
searchable_by =
case status.compute_searchability
case status.compute_searchability_activitypub
when 'public'
[COLLECTIONS[:public]]
when 'unlisted' # Followers only in kmyblue (generics: private)

View file

@ -52,6 +52,7 @@
# requested_review_at :datetime
# group_allow_private_message :boolean
# searchability :integer default("private"), not null
# dissubscribable :boolean default(FALSE), not null
#
class Account < ApplicationRecord

209
app/models/antenna.rb Normal file
View file

@ -0,0 +1,209 @@
# == Schema Information
#
# Table name: antennas
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# list_id :bigint(8) not null
# title :string default(""), not null
# keywords :jsonb
# exclude_keywords :jsonb
# any_domains :boolean default(TRUE), not null
# any_tags :boolean default(TRUE), not null
# any_accounts :boolean default(TRUE), not null
# any_keywords :boolean default(TRUE), not null
# available :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# expires_at :datetime
# with_media_only :boolean default(FALSE), not null
#
class Antenna < ApplicationRecord
include Expireable
has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy
has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy
has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy
belongs_to :account
belongs_to :list
scope :all_keywords, -> { where(any_keywords: true) }
scope :all_domains, -> { where(any_domains: true) }
scope :all_accounts, -> { where(any_accounts: true) }
scope :all_tags, -> { where(any_tags: true) }
scope :availables, -> { where(available: true).where(Arel.sql('any_keywords = FALSE OR any_domains = FALSE OR any_accounts = FALSE OR any_tags = FALSE')) }
validate :list_owner
def list_owner
raise Mastodon::ValidationError, I18n.t('antennas.errors.invalid_list_owner') if list.account != account
end
def enabled?
enabled_config? && !expired?
end
def enabled_config?
available && enabled_config_raws?
end
def enabled_config_raws?
!(any_keywords && any_domains && any_accounts && any_tags)
end
def expires_in
return @expires_in if defined?(@expires_in)
return nil if expires_at.nil?
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
end
def context
context = []
context << 'domain' if !any_domains
context << 'tag' if !any_tags
context << 'keyword' if !any_keywords
context << 'account' if !any_accounts
context
end
def list=(list_id)
list_id = list_id.to_i if list_id.is_a?(String)
if list_id.is_a?(Numeric)
self[:list_id] = list_id
else
self[:list] = list_id
end
end
def keywords_raw
return '' if !keywords.present?
keywords.join("\n")
end
def keywords_raw=(raw)
keywords = raw.split(/\R/).filter { |r| r.present? && r.length >= 2 }.uniq
self[:keywords] = keywords
self[:any_keywords] = !keywords.any? && !exclude_keywords&.any?
end
def exclude_keywords_raw
return '' if !exclude_keywords.present?
exclude_keywords.join("\n")
end
def exclude_keywords_raw=(raw)
exclude_keywords = raw.split(/\R/).filter { |r| r.present? }.uniq
self[:exclude_keywords] = exclude_keywords
self[:any_keywords] = !keywords&.any? && !exclude_keywords.any?
end
def tags_raw
antenna_tags.where(exclude: false).map(&:tag).map(&:name).join("\n")
end
def tags_raw=(raw)
return if tags_raw == raw
tag_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('#') ? r[1..-1] : r }.uniq
antenna_tags.where(exclude: false).destroy_all
Tag.find_or_create_by_names(tag_names).each do |tag|
antenna_tags.create!(tag: tag, exclude: false)
end
self[:any_tags] = !tag_names.any?
end
def exclude_tags_raw
antenna_tags.where(exclude: true).map(&:tag).map(&:name).join("\n")
end
def exclude_tags_raw=(raw)
return if exclude_tags_raw == raw
tag_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('#') ? r[1..-1] : r }.uniq
antenna_tags.where(exclude: true).destroy_all
Tag.find_or_create_by_names(tag_names).each do |tag|
antenna_tags.create!(tag: tag, exclude: true)
end
end
def domains_raw
antenna_domains.where(exclude: false).map(&:name).join("\n")
end
def domains_raw=(raw)
return if domains_raw == raw
domain_names = raw.split(/\R/).filter { |r| r.present? }.uniq
antenna_domains.where(exclude: false).destroy_all
domain_names.each do |domain|
antenna_domains.create!(name: domain, exclude: false)
end
self[:any_domains] = !domain_names.any?
end
def exclude_domains_raw
antenna_domains.where(exclude: true).map(&:name).join("\n")
end
def exclude_domains_raw=(raw)
return if exclude_domains_raw == raw
domain_names = raw.split(/\R/).filter { |r| r.present? }.uniq
antenna_domains.where(exclude: true).destroy_all
domain_names.each do |domain|
antenna_domains.create!(name: domain, exclude: true)
end
end
def accounts_raw
antenna_accounts.where(exclude: false).map(&:account).map { |account| account.domain ? "@#{account.username}@#{account.domain}" : "@#{account.username}" }.join("\n")
end
def accounts_raw=(raw)
return if accounts_raw == raw
account_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('@') ? r[1..-1] : r }.uniq
hit = false
antenna_accounts.where(exclude: false).destroy_all
account_names.each do |name|
username, domain = name.split('@')
account = Account.find_by(username: username, domain: domain)
if account.present?
antenna_accounts.create!(account: account, exclude: false)
hit = true
end
end
self[:any_accounts] = !hit
end
def exclude_accounts_raw
antenna_accounts.where(exclude: true).map(&:account).map { |account| account.domain ? "@#{account.username}@#{account.domain}" : "@#{account.username}" }.join("\n")
end
def exclude_accounts_raw=(raw)
return if exclude_accounts_raw == raw
account_names = raw.split(/\R/).filter { |r| r.present? }.map { |r| r.start_with?('@') ? r[1..-1] : r }.uniq
hit = false
antenna_accounts.where(exclude: true).destroy_all
account_names.each do |name|
username, domain = name.split('@')
account = Account.find_by(username: username, domain: domain)
if account.present?
antenna_accounts.create!(account: account, exclude: true)
hit = true
end
end
end
end

View file

@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: antenna_accounts
#
# id :bigint(8) not null, primary key
# antenna_id :bigint(8) not null
# account_id :bigint(8) not null
# exclude :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class AntennaAccount < ApplicationRecord
belongs_to :antenna
belongs_to :account
validates :account_id, uniqueness: { scope: :antenna_id }
end

View file

@ -0,0 +1,16 @@
# == Schema Information
#
# Table name: antenna_domains
#
# id :bigint(8) not null, primary key
# antenna_id :bigint(8) not null
# name :string
# exclude :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class AntennaDomain < ApplicationRecord
belongs_to :antenna
end

17
app/models/antenna_tag.rb Normal file
View file

@ -0,0 +1,17 @@
# == Schema Information
#
# Table name: antenna_tags
#
# id :bigint(8) not null, primary key
# antenna_id :bigint(8) not null
# tag_id :bigint(8) not null
# exclude :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class AntennaTag < ApplicationRecord
belongs_to :antenna
belongs_to :tag
end

View file

@ -39,6 +39,8 @@ module AccountAssociations
has_many :report_notes, dependent: :destroy
has_many :custom_filters, inverse_of: :account, dependent: :destroy
has_many :antennas, inverse_of: :account, dependent: :destroy
has_many :antenna_accounts, inverse_of: :account, dependent: :destroy
# Moderation notes
has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
@ -46,6 +48,10 @@ module AccountAssociations
has_many :account_warnings, dependent: :destroy, inverse_of: :account
has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
# Antennas (that the account is on, not owned by the account)
has_many :antenna_accounts, inverse_of: :account, dependent: :destroy
has_many :joined_antennas, class_name: 'Antenna', through: :antenna_accounts, source: :antenna
# Lists (that the account is on, not owned by the account)
has_many :list_accounts, inverse_of: :account, dependent: :destroy
has_many :lists, through: :list_accounts

View file

@ -23,6 +23,7 @@ class List < ApplicationRecord
has_many :list_accounts, inverse_of: :list, dependent: :destroy
has_many :accounts, through: :list_accounts
has_many :antennas, inverse_of: :list, dependent: :destroy
validates :title, presence: true

View file

@ -36,7 +36,7 @@ class MediaAttachment < ApplicationRecord
include RoutingHelper
LOCAL_STATUS_ATTACHMENT_MAX = 4
ACTIVITYPUB_STATUS_ATTACHMENT_MAX = 8
ACTIVITYPUB_STATUS_ATTACHMENT_MAX = 16
enum type: { :image => 0, :gifv => 1, :video => 2, :unknown => 3, :audio => 4 }
enum processing: { :queued => 0, :in_progress => 1, :complete => 2, :failed => 3 }, _prefix: true

View file

@ -291,14 +291,6 @@ class Status < ApplicationRecord
end
end
def ordered_media_attachments_original_mastodon
ordered_media_attachments.take(4)
end
def ordered_media_attachments_extra
ordered_media_attachments.drop(4).take(4)
end
def replies_count
status_stat&.replies_count || 0
end
@ -380,6 +372,11 @@ class Status < ApplicationRecord
'private'
end
def compute_searchability_activitypub
return 'unlisted' if public_unlisted_visibility? && public_searchability?
compute_searchability
end
after_create_commit :increment_counter_caches
after_destroy_commit :decrement_counter_caches

View file

@ -27,6 +27,8 @@ class Tag < ApplicationRecord
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
has_many :followers, through: :passive_relationships, source: :account
has_one :antenna_tag, dependent: :destroy, inverse_of: :tag
HASHTAG_SEPARATORS = "_\u00B7\u30FB\u200c"
HASHTAG_FIRST_SEQUENCE_CHUNK_ONE = "[[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}]"
HASHTAG_FIRST_SEQUENCE_CHUNK_TWO = "[[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_]"

View file

@ -7,13 +7,13 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context :security
context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by
:moved_to, :property_value, :discoverable, :olm, :suspended, :searchable_by, :subscribable_by
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary,
:url, :manually_approves_followers,
:discoverable, :published, :searchable_by
:discoverable, :published, :searchable_by, :subscribable_by
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
@ -166,6 +166,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
ActivityPub::TagManager.instance.account_searchable_by(object)
end
def subscribable_by
ActivityPub::TagManager.instance.subscribable_by(object)
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end

View file

@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
include FormattingHelper
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
:note, :url, :avatar, :avatar_static, :header, :header_static, :searchability,
:note, :url, :avatar, :avatar_static, :header, :header_static, :searchability, :dissubscribable,
:followers_count, :following_count, :statuses_count, :last_status_at
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?

View file

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

View file

@ -55,6 +55,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX,
max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
},
@ -97,7 +98,6 @@ class REST::InstanceSerializer < ActiveModel::Serializer
def fedibird_capabilities
capabilities = [
:emoji_reaction,
:kmyblue_extra_media_attachments,
:kmyblue_visibility_public_unlisted,
:enable_wide_emoji,
:enable_wide_emoji_reaction,

View file

@ -22,8 +22,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
belongs_to :application, if: :show_application?
belongs_to :account, serializer: REST::AccountSerializer
has_many :ordered_media_attachments_original_mastodon, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :ordered_media_attachments_extra, key: :media_attachments_ex, serializer: REST::MediaAttachmentSerializer
has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer

View file

@ -65,6 +65,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
statuses: {
max_characters: StatusLengthValidator::MAX_CHARS,
max_media_attachments: MediaAttachment::LOCAL_STATUS_ATTACHMENT_MAX,
max_media_attachments_from_activitypub: MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX,
characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
},
@ -107,7 +108,6 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
def fedibird_capabilities
capabilities = [
:emoji_reaction,
:kmyblue_extra_media_attachments,
:kmyblue_visibility_public_unlisted,
:enable_wide_emoji,
:enable_wide_emoji_reaction,

View file

@ -78,6 +78,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.suspension_origin = :local if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence?
@account.searchability = :private # not null
@account.dissubscribable = false # not null
@account.save
end
@ -115,6 +116,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.discoverable = @json['discoverable'] || false
@account.searchability = searchability_from_audience
@account.dissubscribable = !subscribable(@account.note)
end
def set_fetchable_key!
@ -249,6 +251,20 @@ class ActivityPub::ProcessAccountService < BaseService
end
end
def subscribable_by
return nil if @json['subscribableBy'].nil?
@subscribable_by = as_array(@json['subscribableBy']).map { |x| value_or_id(x) }
end
def subscribable(note)
if subscribable_by.nil?
!note.include?('[subscribable:no]')
else
subscribable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
end
end
def property_values
return unless @json['attachment'].is_a?(Array)

View file

@ -8,6 +8,7 @@ class DeleteAccountService < BaseService
account_pins
active_relationships
aliases
antennas
block_relationships
blocked_by_relationships
conversation_mutes

View file

@ -49,6 +49,7 @@ class FanOutOnWriteService < BaseService
when :public, :unlisted, :public_unlisted, :private
deliver_to_all_followers!
deliver_to_lists!
deliver_to_antennas! if [:public, :public_unlisted].include?(@status.visibility.to_sym) && !@status.account.dissubscribable
when :limited
deliver_to_mentioned_followers!
else
@ -115,6 +116,35 @@ class FanOutOnWriteService < BaseService
end
end
def deliver_to_antennas!
lists = []
antennas = Antenna.availables
antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.availables.left_joins(:antenna_accounts) .where(antenna_accounts: { exclude: false, account: @status.account }))
antennas = antennas.left_joins(:antenna_domains) .where(any_domains: true) .or(Antenna.availables.left_joins(:antenna_accounts).left_joins(:antenna_domains) .where(antenna_domains: { exclude: false, name: @status.account.domain }))
antennas = antennas.left_joins(:antenna_tags) .where(any_tags: true) .or(Antenna.availables.left_joins(:antenna_accounts).left_joins(:antenna_domains).left_joins(:antenna_tags).where(antenna_tags: { exclude: false, tag: @status.tags }))
antennas = antennas.where(account: @status.account.followers) if @status.visibility.to_sym == :unlisted
antennas.in_batches do |ans|
ans.each do |antenna|
next if !antenna.enabled?
next if @status.account.blocking?(antenna.account)
next if antenna.keywords.any? && !([nil, :public].include?(@status.searchability&.to_sym))
next if antenna.keywords.any? && !antenna.keywords.any? { |keyword| @status.text.include?(keyword) }
next if antenna.exclude_keywords.any? && antenna.exclude_keywords.any? { |keyword| @status.text.include?(keyword) }
next if antenna.antenna_accounts.where(exclude: true, account: @status.account).any?
next if antenna.antenna_domains.where(exclude: true, name: @status.account.domain).any?
next if antenna.antenna_tags.where(exclude: true, tag: @status.tags).any?
lists << antenna.list
end
end
lists = lists.uniq
if lists.any?
FeedInsertWorker.push_bulk(lists) do |list|
[@status.id, list.id, 'list', { 'update' => update? }]
end
end
end
def deliver_to_mentioned_followers!
@status.mentions.joins(:account).merge(@account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
FeedInsertWorker.push_bulk(mentions) do |mention|

View file

@ -0,0 +1,74 @@
.filters-list__item{ class: [(antenna.expired? || !antenna.enabled_config?) && 'expired'] }
= link_to edit_antenna_path(antenna), class: 'filters-list__item__title' do
= antenna.title
- if !antenna.enabled_config?
.expiration{ title: t('antennas.index.disabled') }
= t('antennas.index.disabled')
- elsif antenna.expires?
.expiration{ title: t('antennas.index.expires_on', date: l(antenna.expires_at)) }
- if antenna.expired?
= t('invites.expired')
- else
= t('antennas.index.expires_in', distance: distance_of_time_in_words_to_now(antenna.expires_at))
.listname
= antenna.list.title
.filters-list__item__permissions
%ul.permissions-list
- unless antenna.antenna_domains.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('sitemap')
.permissions-list__item__text
.permissions-list__item__text__title
= t('antennas.index.domains', count: antenna.antenna_domains.size)
.permissions-list__item__text__type
- domains = antenna.antenna_domains.map { |domain| domain.name }
- domains = domains.take(5) + ['…'] if domains.size > 5 # TODO
= domains.join(', ')
- unless antenna.antenna_accounts.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('users')
.permissions-list__item__text
.permissions-list__item__text__title
= t('antennas.index.accounts', count: antenna.antenna_accounts.size)
.permissions-list__item__text__type
- accounts = antenna.antenna_accounts.map { |account| account.account.domain ? "@#{account.account.username}@#{account.account.domain}" : "@#{account.account.username}" }
- accounts = accounts.take(5) + ['…'] if accounts.size > 5 # TODO
= accounts.join(', ')
- unless antenna.keywords.nil? || antenna.keywords.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('paragraph')
.permissions-list__item__text
.permissions-list__item__text__title
= t('antennas.index.keywords', count: antenna.keywords.size)
.permissions-list__item__text__type
- keywords = antenna.keywords
- keywords = keywords.take(5) + ['…'] if keywords.size > 5 # TODO
= keywords.join(', ')
- unless antenna.antenna_tags.empty?
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('hashtag')
.permissions-list__item__text
.permissions-list__item__text__title
= t('antennas.index.tags', count: antenna.antenna_tags.size)
.permissions-list__item__text__type
- tags = antenna.antenna_tags.map { |tag| tag.tag.name }
- tags = keywords.take(5) + ['…'] if tags.size > 5 # TODO
= tags.join(', ')
.announcements-list__item__action-bar
.announcements-list__item__meta
- if antenna.enabled_config_raws?
= t('antennas.index.contexts', contexts: antenna.context.map { |context| I18n.t("antennas.contexts.#{context}") }.join(', '))
- else
= t('antennas.errors.empty_contexts')
%div
= table_link_to 'pencil', t('antennas.edit.title'), edit_antenna_path(antenna)
= table_link_to 'times', t('antennas.index.delete'), antenna_path(antenna), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }

View file

@ -0,0 +1,51 @@
%p= t 'antennas.edit.description'
%hr.spacer/
.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-row
.fields-group.fields-row__column.fields-row__column-6
= f.input :list, collection: lists, wrapper: :with_label, label_method: lambda { |list| list.title }, label: t('antennas.edit.list'), selected: f.object.list&.id, hint: false
.fields-group.fields-row__column.fields-row__column-6
= f.input :available, wrapper: :with_label, label: t('antennas.edit.available'), hint: false
%hr.spacer/
%p.hint= t 'antennas.edit.hint'
%hr.spacer/
%h4= t('antennas.contexts.domain')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :domains_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.domains_raw')
.fields-row__column.fields-row__column-6.fields-group
= f.input :exclude_domains_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_domains_raw')
%h4= t('antennas.contexts.account')
%p.hint= t 'antennas.edit.accounts_hint'
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :accounts_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.accounts_raw')
.fields-row__column.fields-row__column-6.fields-group
= f.input :exclude_accounts_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_accounts_raw')
%h4= t('antennas.contexts.tag')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :tags_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.tags_raw')
.fields-row__column.fields-row__column-6.fields-group
= f.input :exclude_tags_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_tags_raw')
%h4= t('antennas.contexts.keyword')
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :keywords_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.keywords_raw')
.fields-row__column.fields-row__column-6.fields-group
= f.input :exclude_keywords_raw, wrapper: :with_label, as: :text, input_html: { rows: 5 }, label: t('antennas.edit.exclude_keywords_raw')

View file

@ -0,0 +1,8 @@
- content_for :page_title do
= t('antennas.edit.title')
= simple_form_for @antenna, url: antenna_path(@antenna), method: :put do |f|
= render 'antenna_fields', f: f, lists: @lists
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

@ -0,0 +1,14 @@
- content_for :page_title do
= t('antennas.index.title')
- content_for :heading_actions do
= link_to t('antennas.new.title'), new_antenna_path, class: 'button'
.flash-message.alert
%strong= t('antennas.beta')
- if @antennas.empty?
.muted-hint.center-text= t 'antennas.index.empty'
- else
.applications-list
= render partial: 'antenna', collection: @antennas

View file

@ -0,0 +1,8 @@
- content_for :page_title do
= t('antennas.new.title')
= simple_form_for @antenna, url: antennas_path do |f|
= render 'antenna_fields', f: f, lists: @lists
.actions
= f.button :button, t('antennas.new.save'), type: :submit

View file

@ -38,6 +38,9 @@
.fields-group
= f.input :hide_collections, as: :boolean, wrapper: :with_label, label: t('simple_form.labels.defaults.setting_hide_network'), hint: t('simple_form.hints.defaults.setting_hide_network')
.fields-group
= f.input :dissubscribable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.dissubscribable')
%hr.spacer/
.fields-row

View file

@ -964,6 +964,44 @@ en:
empty: You have no aliases.
hint_html: If you want to move from another account to this one, here you can create an alias, which is required before you can proceed with moving followers from the old account to this one. This action by itself is <strong>harmless and reversible</strong>. <strong>The account migration is initiated from the old account</strong>.
remove: Unlink alias
antennas:
beta: This function is in beta.
contexts:
account: Accounts
domain: Domains
keyword: Keywords
tag: Tags
edit:
accounts_hint: \@askyq or @askyq@example.com
accounts_raw: Account list
available: Available
description: アンテナは、サーバーが認識した全ての公開・ローカル公開投稿のうち、検索許可が「公開」または明示的に設定されていないもの(検索許可システムに対応していないサーバーからの投稿)、かつ購読を拒否していないすべてのアカウントからの投稿が対象です。検出された投稿は、指定したリストに追加されます。
domains_raw: Domain list
exclude_accounts_raw: Excluding account list
exclude_domains_raw: Excluding domain list
exclude_keywords_raw: Excluding keyword list
exclude_tags_raw: Excluding hashtag list
hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。
keywords_raw: Keyword list
list: Destination list
tags_raw: Hashtag list
title: Edit antenna
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.
empty_contexts: No contexts! You must set any context filters
invalid_context: None or invalid context supplied
invalid_list_owner: This list is not yours
index:
contexts: Antennas in %{contexts}
delete: Delete
disabled: Disabled
empty: You have no antennas.
expires_in: Expires in %{distance}
expires_on: Expires on %{date}
title: Antennas
new:
save: Save new antenna
title: Add new antenna
appearance:
advanced_web_interface: Advanced web interface
advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.'

View file

@ -946,6 +946,50 @@ ja:
empty: エイリアスがありません。
hint_html: 他のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。エイリアス自体は<strong>無害で、取り消す</strong>ことができます。<strong>引っ越しは以前のアカウント側から開始する必要があります</strong>。
remove: エイリアスを削除
antennas:
beta: アンテナ機能はベータ版です。今後、予告なく全データリセット・機能削除を行う場合があります。この機能の存在は外部に積極的に宣伝しないよう、ご協力をお願いします。
contexts:
account: アカウント
domain: ドメイン
keyword: キーワード
tag: ハッシュタグ
errors:
empty_contexts: 絞り込み条件が1つも指定されていないため無効です除外条件はカウントされません
invalid_list_owner: これはあなたのリストではありません
edit:
accounts_hint: ローカルアカウントの場合は「@info」、リモートアカウントの場合は「@info@example.com」の形式で指定します。サーバーが認識していないアカウントは保存時に自動的に削除されます。
accounts_raw: 絞り込むアカウント
available: 有効
description: アンテナは、サーバーが認識した全ての公開・ローカル公開投稿のうち、検索許可が「公開」または明示的に設定されていないもの(検索許可システムに対応していないサーバーからの投稿)、かつ購読を拒否していないすべてのアカウントからの投稿が対象です。検出された投稿は、指定したリストに追加されます。
domains_raw: 絞り込むドメイン
exclude_accounts_raw: 除外するアカウント
exclude_domains_raw: 除外するドメイン
exclude_keywords_raw: 除外するキーワード
exclude_tags_raw: 除外するハッシュタグ
hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。絞り込み条件除外条件ではないは最低1つ設定しなければいけません。
keywords_raw: 絞り込むキーワード
list: 投稿配置先リスト
tags_raw: 絞り込むハッシュタグ
title: アンテナを編集
index:
accounts:
other: "%{count}件のアカウント"
contexts: "%{contexts}のアンテナ"
delete: 削除
disabled: 無効
domains:
other: "%{count}件のドメイン"
empty: アンテナはありません。
expires_in: "%{distance}で期限切れ"
expires_on: 有効期限 %{date}
keywords:
other: "%{count}件のキーワード"
tags:
other: "%{count}件のタグ"
title: アンテナ
new:
save: 新規アンテナを保存
title: 新規アンテナを追加
appearance:
advanced_web_interface: 上級者向けUI
advanced_web_interface_hint: ディスプレイを幅いっぱいまで活用したい場合、上級者向け UI をおすすめします。ホーム、通知、連合タイムライン、更にはリストやハッシュタグなど、様々な異なるカラムから望む限りの情報を一度に受け取れるような設定が可能になります。

View file

@ -38,6 +38,7 @@ en:
current_username: To confirm, please enter the username of the current account
digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
discoverable: Allow your account to be discovered by strangers through recommendations, trends and other features
dissubscribable: Your post is not picked by antenna
email: You will be sent a confirmation e-mail
fields: You can have up to 4 items displayed as a table on your profile
group: Reps sent to this account will be automatically BT'd and distributed to all accounts you follow!
@ -179,6 +180,7 @@ en:
data: Data
discoverable: Suggest account to others
display_name: Display name
dissubscribable: Reject any subscriptions
email: E-mail address
expires_in: Expire after
fields: Profile metadata

View file

@ -38,6 +38,7 @@ ja:
current_username: 確認のため、現在のアカウントのユーザー名を入力してください
digest: 長期間使用していない場合と不在時に返信を受けた場合のみ送信されます
discoverable: レコメンド、トレンド、その他の機能により、あなたのアカウントを他の人から見つけられるようにします
dissubscribable: あなたの投稿はすべてのアンテナに掲載されなくなります。Fedibirdからの購読やMisskeyのアンテナを拒否することはできません
email: 確認のメールが送信されます
fields: プロフィールに表として4つまでの項目を表示することができます
group: このアカウントに送られたメンションは自動でBTされ、フォローしている全てのアカウントに配信されます
@ -180,6 +181,7 @@ ja:
data: データ
discoverable: ディレクトリに掲載する
display_name: 表示名
dissubscribable: 購読を拒否する
email: メールアドレス
expires_in: 有効期限
fields: プロフィール補足情報

View file

@ -17,6 +17,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? }
n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
n.item :antennas, safe_join([fa_icon('wifi fw'), t('antennas.index.title')]), antennas_path, highlights_on: %r{/antennas}, if: -> { current_user.functional? }
n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional_or_moved? }
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_path do |s|

View file

@ -216,6 +216,7 @@ Rails.application.routes.draw do
end
end
end
resources :antennas, except: [:show]
resource :relationships, only: [:show, :update]
resource :statuses_cleanup, controller: :statuses_cleanup, only: [:show, :update]
@ -602,6 +603,7 @@ Rails.application.routes.draw do
resources :followers, only: :index, controller: 'accounts/follower_accounts'
resources :following, only: :index, controller: 'accounts/following_accounts'
resources :lists, only: :index, controller: 'accounts/lists'
resources :antennas, only: :index, controller: 'accounts/antennas'
resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
resources :featured_tags, only: :index, controller: 'accounts/featured_tags'
@ -633,6 +635,10 @@ Rails.application.routes.draw do
resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
end
resources :antennas, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'antennas/accounts'
end
namespace :featured_tags do
get :suggestions, to: 'suggestions#index'
end

View file

@ -0,0 +1,40 @@
class CreateAntennas < ActiveRecord::Migration[6.1]
def change
create_table :antennas do |t|
t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :list, null: false, foreign_key: { on_delete: :cascade }
t.string :title, null: false, default: ''
t.jsonb :keywords
t.jsonb :exclude_keywords
t.boolean :any_domains, null: false, default: true, index: true
t.boolean :any_tags, null: false, default: true, index: true
t.boolean :any_accounts, null: false, default: true, index: true
t.boolean :any_keywords, null: false, default: true, index: true
t.boolean :available, null: false, default: true, index: true
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
t.datetime :expires_at
end
create_table :antenna_domains do |t|
t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade }
t.string :name, index: true
t.boolean :exclude, null: false, default: false, index: true
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
create_table :antenna_tags do |t|
t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :tag, null: false, foreign_key: { on_delete: :cascade }
t.boolean :exclude, null: false, default: false, index: true
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
create_table :antenna_accounts do |t|
t.belongs_to :antenna, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }
t.boolean :exclude, null: false, default: false, index: true
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
end
end

View file

@ -0,0 +1,6 @@
class AddDissubscribableToAccounts < ActiveRecord::Migration[6.1]
def change
add_column :antennas, :with_media_only, :boolean, null: false, default: false, index: true
add_column :accounts, :dissubscribable, :boolean, null: false, default: false
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2023_04_14_010523) do
ActiveRecord::Schema.define(version: 2023_04_23_233429) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -189,6 +189,7 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do
t.datetime "requested_review_at"
t.boolean "group_allow_private_message"
t.integer "searchability", default: 2, null: false
t.boolean "dissubscribable", default: false, null: false
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id", where: "(moved_to_account_id IS NOT NULL)"
@ -251,6 +252,63 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do
t.bigint "status_ids", array: true
end
create_table "antenna_accounts", force: :cascade do |t|
t.bigint "antenna_id", null: false
t.bigint "account_id", null: false
t.boolean "exclude", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_antenna_accounts_on_account_id"
t.index ["antenna_id"], name: "index_antenna_accounts_on_antenna_id"
t.index ["exclude"], name: "index_antenna_accounts_on_exclude"
end
create_table "antenna_domains", force: :cascade do |t|
t.bigint "antenna_id", null: false
t.string "name"
t.boolean "exclude", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["antenna_id"], name: "index_antenna_domains_on_antenna_id"
t.index ["exclude"], name: "index_antenna_domains_on_exclude"
t.index ["name"], name: "index_antenna_domains_on_name"
end
create_table "antenna_tags", force: :cascade do |t|
t.bigint "antenna_id", null: false
t.bigint "tag_id", null: false
t.boolean "exclude", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["antenna_id"], name: "index_antenna_tags_on_antenna_id"
t.index ["exclude"], name: "index_antenna_tags_on_exclude"
t.index ["tag_id"], name: "index_antenna_tags_on_tag_id"
end
create_table "antennas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "list_id", null: false
t.string "title", default: "", null: false
t.jsonb "keywords"
t.jsonb "exclude_keywords"
t.boolean "any_domains", default: true, null: false
t.boolean "any_tags", default: true, null: false
t.boolean "any_accounts", default: true, null: false
t.boolean "any_keywords", default: true, null: false
t.boolean "available", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "expires_at"
t.boolean "with_media_only", default: false, null: false
t.index ["account_id"], name: "index_antennas_on_account_id"
t.index ["any_accounts"], name: "index_antennas_on_any_accounts"
t.index ["any_domains"], name: "index_antennas_on_any_domains"
t.index ["any_keywords"], name: "index_antennas_on_any_keywords"
t.index ["any_tags"], name: "index_antennas_on_any_tags"
t.index ["available"], name: "index_antennas_on_available"
t.index ["list_id"], name: "index_antennas_on_list_id"
end
create_table "appeals", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "account_warning_id", null: false
@ -1173,6 +1231,13 @@ ActiveRecord::Schema.define(version: 2023_04_14_010523) do
add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade
add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade
add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade
add_foreign_key "antenna_accounts", "accounts", on_delete: :cascade
add_foreign_key "antenna_accounts", "antennas", on_delete: :cascade
add_foreign_key "antenna_domains", "antennas", on_delete: :cascade
add_foreign_key "antenna_tags", "antennas", on_delete: :cascade
add_foreign_key "antenna_tags", "tags", on_delete: :cascade
add_foreign_key "antennas", "accounts", on_delete: :cascade
add_foreign_key "antennas", "lists", on_delete: :cascade
add_foreign_key "appeals", "account_warnings", on_delete: :cascade
add_foreign_key "appeals", "accounts", column: "approved_by_account_id", on_delete: :nullify
add_foreign_key "appeals", "accounts", column: "rejected_by_account_id", on_delete: :nullify