Merge branch 'kb_development' into kb_migration

This commit is contained in:
KMY 2023-08-24 09:21:01 +09:00
commit 3742720b5d
23 changed files with 386 additions and 59 deletions

View file

@ -2,36 +2,36 @@
[![Ruby Testing](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/kmycode/mastodon/actions/workflows/test-ruby.yml)
kmyblue は[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のための Mastodon を目指して開発しました。
kmyblueは[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のためのMastodonを目指して開発しました。
kmyblue はフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしての kmyblue をさします。
kmyblueはフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしてのkmyblueをさします。
kmyblue は AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。また ActivityPub に参加することもできます。サーバーkmyblueは創作作家向けのものですが、フォークとしてのkmyblueは作者の嫌いな政治に関する過激な話を取り扱うコミュニティ、創作活動の一部エロ関係含むまたは全体を否定するコミュニティなども平等にお使いいただけますし、サーバーkmyblueのルールを適用する必要もありません。
ただし kmyblue においてテストコードは飾りでしかないため、不具合が発生しても自己責任になります。既知のバグもいくつかありますし、直す予定のないものも含まれます。
kmyblueは AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。また ActivityPub に参加することもできます。サーバーkmyblueは創作作家向けのものですが、フォークとしてのkmyblueは作者の嫌いな政治に関する過激な話を取り扱うコミュニティ、創作活動の一部エロ関係含むまたは全体を否定するコミュニティなども平等にお使いいただけますし、サーバーkmyblueのルールを適用する必要もありません。
ただしkmyblueにおいてテストコードは飾りでしかないため、不具合が発生しても自己責任になります。既知のバグもいくつかありますし、直す予定のないものも含まれます。
テストコード、Lint どちらも動いています。
## kmyblue の強み
## kmyblueの強み
### 本家 Mastodon への積極的追従
### 本家Mastodonへの積極的追従
kmyblue は、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家 Mastodon に積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。
kmyblue の追加機能そのままに、Mastodon の新機能も利用できるよう調整を行います。
kmyblueは、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。
kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。
### 絵文字リアクション対応
kmyblue は絵文字リアクションに対応しているフォークの1つです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家 Mastodon には搭載されていません。
kmyblueは絵文字リアクションに対応しているフォークのつです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。
## kmyblue のブランチ
## kmyblueのブランチ
- **main** - 現在はメンテナンスされていません
- **kb_development** - 現在 kmyblue 本体で使われているソースコードです
- **kb_migration** - 本家 Mastodon への追従を目的としたブランチです。`kb_development`上で開発を進めているときに利用します
- **kb_migration_development** - 本家 Mastodon へ追従し、かつその上で開発するときに使うブランチです。最新の本家コードでリファクタリングが行われ、`kb_development``kb_migration`の互換性の維持が困難になったときに利用します。ここで追加された機能は原則、本家 Mastodon のバージョンアップと同時に`kb_development`に反映されます
- **kb_development** - 現在kmyblue本体で使われているソースコードです
- **kb_migration** - 本家Mastodonへの追従を目的としたブランチです。`kb_development`上で開発を進めているときに利用します
- **kb_migration_development** - 本家Mastodonへ追従し、かつその上で開発するときに使うブランチです。最新の本家コードでリファクタリングが行われ、`kb_development``kb_migration`の互換性の維持が困難になったときに利用します。ここで追加された機能は原則、本家Mastodonのバージョンアップと同時に`kb_development`に反映されます
## 本家 Mastodon からの追加機能
## 本家Mastodonからの追加機能
kmyblue は、本家 Mastodon にいくつかの改造を加えています。以下に示します。
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。
### ローカル公開
@ -39,24 +39,35 @@ kmyblue は、本家 Mastodon にいくつかの改造を加えています。
### スタンプ(絵文字リアクション)
kmyblue 内での呼称はスタンプですが、一般には絵文字リアクションと呼ばれる機能です。自分や他人の投稿に絵文字をつけることができます。kmyblue のスタンプは Fedibird の絵文字リアクションと互換性のある API を持っているため、Fedibird 対応アプリで kmyblue のスタンプ機能を利用できる場合があります。
kmyblue内での呼称はスタンプですが、一般には絵文字リアクションと呼ばれる機能です。自分や他人の投稿に絵文字をつけることができます。kmyblueのスタンプは Fedibird の絵文字リアクションと互換性のある API を持っているため、Fedibird 対応アプリでkmyblueのスタンプ機能を利用できる場合があります。
kmyblue は、1つのアカウントが1つの投稿に複数のスタンプ(絵文字リアクション)を最大3個までつけることが可能です。また、下記機能にも対応しています。
kmyblueは、つのアカウントがつの投稿に複数のスタンプ絵文字リアクションを最大個までつけることが可能です。また、下記機能にも対応しています。
- 他のサーバーのアカウントがつけたスタンプに相乗りする
- 自分がスタンプを付けた投稿一覧を見る
- トレンド投稿の選定条件にスタンプを付けたアカウントの数を考慮する
- 投稿の自動削除で削除条件にスタンプの数を指定する
kmyblue は、他のサーバーの投稿にスタンプをつけることで、相手サーバーに情報を送信します。ただしスタンプに対応していないサーバーにおいては、通知されることはありません。
kmyblueは、他のサーバーの投稿にスタンプをつけることで、相手サーバーに情報を送信します。ただしスタンプに対応していないサーバーにおいては、通知されることはありません。
### アンテナ
「自分はフォローしていないが連合タイムラインに流れているアカウント」の投稿を購読することが可能です。アンテナはドメイン、アカウント、ハッシュタグ、キーワードの4種類の絞り込み条件を持ち、複合指定することで AND 条件として働きます。アンテナによって検出された投稿は、指定されたリスト、またはホームタイムラインに追加されます。
「自分はフォローしていないが連合タイムラインに流れているアカウント」の投稿を購読することが可能です。アンテナはドメイン、アカウント、ハッシュタグ、キーワードの4種類の絞り込み条件を持ち、複合指定することで AND 条件として働きます。アンテナによって検出された投稿は、指定されたリスト、またはホームタイムラインに追加されます。
アンテナ専用のタイムラインも存在し、ここではアンテナで拾った投稿が流れます。これはリストやホームに配置しなくても容易に確認できます。
ドメイン購読において、自分自身のドメインを指定できることも特長のつです。また、STLソーシャルタイムラインモードにも対応しています。
自分の投稿がアンテナに検出されるのを拒否することもできます。この拒否設定は、ActivityPub で他サーバーにも共有されますが、対応するかはそれぞれの判断に委ねられます。
絞り込まれた投稿は、さらにドメイン、アカウント、ハッシュタグ、キーワードの種類の条件を指定して除外することができます。これはOR条件として働きます。
アンテナの条件指定は複雑ですが、Webクライアントに搭載された編集画面では、事前の説明がなくても条件指定の落とし穴に気づきやすいようにしています。
自分の投稿がアンテナに検出されるのを拒否することもできます。この拒否設定は、ActivityPubで他サーバーにも共有されますが、対応するかはそれぞれの判断に委ねられます。
### サークル
自分のフォロワーの中でも特に対象を絞ってサークルという単位にまとめ、対象アカウントのみが閲覧可能な投稿を送信できます。ただしこれはMastodonサーバーとしか共有できません。4.2.0-beta2現在、本家Mastodonではバグのため正常に受信できません
相互フォロー限定投稿にも対応しています。
### 期間限定投稿
@ -66,7 +77,7 @@ kmyblue は、他のサーバーの投稿にスタンプをつけることで、
### グループ
kmyblue はグループ機能に対応しています。グループのフォロワーからグループアカウントへのメンションはすべてブーストされ、グループのフォロワー全員に届きます。なおこれは本家 Mastodon でも今後実装予定の機能です。
kmyblueはグループ機能に対応しています。グループのフォロワーからグループアカウントへのメンションはすべてブーストされ、グループのフォロワー全員に届きます。なおこれは本家Mastodonでも今後実装予定の機能です。
### ドメインブロックの拡張
@ -81,7 +92,7 @@ kmyblue はグループ機能に対応しています。グループのフォロ
#### Misskey 対策
Misskey およびそのフォークCalckey など)は、**フォローしていないアカウントの未収載投稿**を自由に検索・購読することができます。これは Mastodon の設計とは根本的に異なる仕様です。kmyblue では、別途手動でドメインブロックデータにフラグを付けたサーバーに限り、そのサーバーに未収載投稿を送信するときに「フォロワーのみ」に変更します。他のサーバーには未収載として送信されます。この動作には、管理者だけでなくユーザー各自の設定も必要になります。
Misskey およびそのフォークCalckey など)は、**フォローしていないアカウントの未収載投稿**を自由に検索・購読することができます。これはMastodonの設計とは根本的に異なる仕様です。kmyblueでは、そのサーバーに未収載投稿を送信するときに「フォロワーのみ」に変更します。他のサーバーには未収載として送信されます。この動作は新規登録したばかりのユーザーにおいてはデフォルトではオフとなっており、ユーザー各自の設定が必要になります。
### モデレーションの拡張
@ -109,11 +120,11 @@ Misskey およびそのフォークCalckey などでは、MFM を利用す
### 投票項目数の拡張
投票について、本家 Mastodon では項目までですが、kmyblue では8個までに拡張しています。
投票について、本家Mastodonでは項目までですが、kmyblueでは個までに拡張しています。
### 連合から送られてくる投稿の添付画像最大数の拡張
本家 Mastodon では個までですが、kmyblue では8個までに拡張しています。ただし Web クライアントでの表示には、各自ユーザーによる設定が必要です。kmyblue ローカルから投稿できる画像の枚数に変更はありません。
本家Mastodonでは個までですが、kmyblueでは個までに拡張しています。ただし Web クライアントでの表示には、各自ユーザーによる設定が必要です。kmyblueローカルから投稿できる画像の枚数に変更はありません。
### 検索許可
@ -121,11 +132,13 @@ Misskey およびそのフォークCalckey などでは、MFM を利用す
これは Fedibird の「検索範囲」機能に対応します。API に互換性はありませんが、ActivityPub 仕様は共通しています。
なおMisskeyからの投稿は、検索許可が自動的に「全て」になります。
### トレンドの拡張
本家マストドンでは、センシティブフラグのついた全ての投稿がトレンドに掲載されません。kmyblue はその中でも、「センシティブフラグはついているが、画像が添付されておらず CW 付きでもない」投稿をトレンドに掲載します。
本家マストドンでは、センシティブフラグのついた全ての投稿がトレンドに掲載されません。kmyblueはその中でも、「センシティブフラグはついているが、画像が添付されておらず CW 付きでもない」投稿をトレンドに掲載します。
本来このような投稿はトレンドに掲載すべきでありませんが、本家 Mastodon の Web クライアントでは文章だけの投稿のセンシティブフラグを自由に操作できないことを理由とした独自対応となります。
本来このような投稿はトレンドに掲載すべきでありませんが、本家Mastodonの Web クライアントでは文章だけの投稿のセンシティブフラグを自由に操作できないことを理由とした独自対応となります。
### Sidekiq ヘルスチェック

View file

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

View file

@ -122,6 +122,10 @@ export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETC
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_EXCLUDE_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST';
export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS';
export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL';
export const fetchAntenna = id => (dispatch, getState) => {
if (getState().getIn(['antennas', id])) {
return;
@ -905,6 +909,15 @@ export const setupAntennaAdder = accountId => (dispatch, getState) => {
dispatch(fetchAccountAntennas(accountId));
};
export const setupExcludeAntennaAdder = accountId => (dispatch, getState) => {
dispatch({
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchExcludeAccountAntennas(accountId));
};
export const fetchAccountAntennas = accountId => (dispatch, getState) => {
dispatch(fetchAccountAntennasRequest(accountId));
@ -930,6 +943,31 @@ export const fetchAccountAntennasFail = (id, err) => ({
err,
});
export const fetchExcludeAccountAntennas = accountId => (dispatch, getState) => {
dispatch(fetchExcludeAccountAntennasRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/exclude_antennas`)
.then(({ data }) => dispatch(fetchExcludeAccountAntennasSuccess(accountId, data)))
.catch(err => dispatch(fetchExcludeAccountAntennasFail(accountId, err)));
};
export const fetchExcludeAccountAntennasRequest = id => ({
type:ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST,
id,
});
export const fetchExcludeAccountAntennasSuccess = (id, antennas) => ({
type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS,
id,
antennas,
});
export const fetchExcludeAccountAntennasFail = (id, err) => ({
type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL,
id,
err,
});
export const addToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
@ -938,3 +976,11 @@ export const removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const addExcludeToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addExcludeToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const removeExcludeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeExcludeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};

View file

@ -59,6 +59,7 @@ const messages = defineMessages({
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' },
add_or_remove_from_exclude_antenna: { id: 'account.add_or_remove_from_exclude_antenna', defaultMessage: 'Add or Remove from antennas as exclusion' },
add_or_remove_from_circle: { id: 'account.add_or_remove_from_circle', defaultMessage: 'Add or Remove from circles' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
@ -106,6 +107,7 @@ class Header extends ImmutablePureComponent {
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onAddToAntenna: PropTypes.func.isRequired,
onAddToExcludeAntenna: PropTypes.func.isRequired,
onAddToCircle: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
@ -332,6 +334,7 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
}
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_antenna), action: this.props.onAddToAntenna });
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_exclude_antenna), action: this.props.onAddToExcludeAntenna });
if (account.getIn(['relationship', 'followed_by'])) {
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_circle), action: this.props.onAddToCircle });
}

View file

@ -28,6 +28,7 @@ export default class Header extends ImmutablePureComponent {
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onAddToAntenna: PropTypes.func.isRequired,
onAddToExcludeAntenna: PropTypes.func.isRequired,
onAddToCircle: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
@ -102,6 +103,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onAddToAntenna(this.props.account);
};
handleAddToExcludeAntenna = () => {
this.props.onAddToExcludeAntenna(this.props.account);
};
handleAddToCircle = () => {
this.props.onAddToCircle(this.props.account);
};
@ -149,6 +154,7 @@ export default class Header extends ImmutablePureComponent {
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
onAddToAntenna={this.handleAddToAntenna}
onAddToExcludeAntenna={this.handleAddToExcludeAntenna}
onAddToCircle={this.handleAddToCircle}
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}

View file

@ -169,6 +169,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
modalType: 'ANTENNA_ADDER',
modalProps: {
accountId: account.get('id'),
isExclude: false,
},
}));
},
onAddToExcludeAntenna (account) {
dispatch(openModal({
modalType: 'ANTENNA_ADDER',
modalProps: {
accountId: account.get('id'),
isExclude: true,
},
}));
},

View file

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { Icon } from 'mastodon/components/icon';
import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/antennas';
import { removeFromAntennaAdder, addToAntennaAdder, removeExcludeFromAntennaAdder, addExcludeToAntennaAdder } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
@ -24,15 +24,20 @@ const MapStateToProps = (state, { antennaId, added }) => ({
const mapDispatchToProps = (dispatch, { antennaId }) => ({
onRemove: () => dispatch(removeFromAntennaAdder(antennaId)),
onAdd: () => dispatch(addToAntennaAdder(antennaId)),
onExcludeRemove: () => dispatch(removeExcludeFromAntennaAdder(antennaId)),
onExcludeAdd: () => dispatch(addExcludeToAntennaAdder(antennaId)),
});
class Antenna extends ImmutablePureComponent {
static propTypes = {
antenna: ImmutablePropTypes.map.isRequired,
isExclude: PropTypes.bool.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
onExcludeRemove: PropTypes.func.isRequired,
onExcludeAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
@ -40,15 +45,31 @@ class Antenna extends ImmutablePureComponent {
added: false,
};
handleRemove = () => {
if (this.props.isExclude) {
this.props.onExcludeRemove();
} else {
this.props.onRemove();
}
};
handleAdd = () => {
if (this.props.isExclude) {
this.props.onExcludeAdd();
} else {
this.props.onAdd();
}
};
render () {
const { antenna, intl, onRemove, onAdd, added } = this.props;
const { antenna, intl, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={this.handleRemove} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={this.handleAdd} />;
}
return (

View file

@ -7,7 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas';
import { setupAntennaAdder, resetAntennaAdder, setupExcludeAntennaAdder } from '../../actions/antennas';
import NewAntennaForm from '../antennas/components/new_antenna_form';
import Account from '../list_adder/components/account';
@ -28,6 +28,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupAntennaAdder(accountId)),
onExcludeInitialize: accountId => dispatch(setupExcludeAntennaAdder(accountId)),
onReset: () => dispatch(resetAntennaAdder()),
});
@ -35,16 +36,22 @@ class AntennaAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
isExclude: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onExcludeInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
antennaIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
const { isExclude, onInitialize, onExcludeInitialize, accountId } = this.props;
if (isExclude) {
onExcludeInitialize(accountId);
} else {
onInitialize(accountId);
}
}
componentWillUnmount () {
@ -53,7 +60,7 @@ class AntennaAdder extends ImmutablePureComponent {
}
render () {
const { accountId, antennaIds } = this.props;
const { accountId, antennaIds, isExclude } = this.props;
return (
<div className='modal-root__modal list-adder'>
@ -65,7 +72,7 @@ class AntennaAdder extends ImmutablePureComponent {
<div className='list-adder__lists'>
{antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} />)}
{antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} isExclude={isExclude} />)}
</div>
</div>
);

View file

@ -20,9 +20,9 @@ const messages = defineMessages({
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added, isExclude }) => ({
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['antennaEditor', isExclude ? 'excludeAccounts' : 'accounts', 'items']).includes(accountId) : added,
added: typeof added === 'undefined' ? state.getIn(['antennaEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;

View file

@ -15,8 +15,8 @@ import Account from './components/account';
import EditAntennaForm from './components/edit_antenna_form';
import Search from './components/search';
const mapStateToProps = (state, { isExclude }) => ({
accountIds: state.getIn(['antennaEditor', isExclude ? 'excludeAccounts' : 'accounts', 'items']),
const mapStateToProps = (state) => ({
accountIds: state.getIn(['antennaEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['antennaEditor', 'suggestions', 'items']),
});

View file

@ -12,7 +12,7 @@ import { Icon } from 'mastodon/components/icon';
import { fetchCircleSuggestions, clearCircleSuggestions, changeCircleSuggestions } from '../../../actions/circles';
const messages = defineMessages({
search: { id: 'circles.search', defaultMessage: 'Search among people you follow' },
search: { id: 'circles.search', defaultMessage: 'Search among people follow you' },
});
const mapStateToProps = state => ({

View file

@ -15,6 +15,8 @@
"about.rules": "サーバーのルール",
"account.account_note_header": "メモ",
"account.add_or_remove_from_antenna": "アンテナから追加または外す",
"account.add_or_remove_from_exclude_antenna": "アンテナ除外条件から追加または外す",
"account.add_or_remove_from_circle": "サークルから追加または外す",
"account.add_or_remove_from_list": "リストから追加または外す",
"account.badges.bot": "Bot",
"account.badges.group": "Group",
@ -95,6 +97,8 @@
"antennas.add_domain_placeholder": "新しいドメイン名",
"antennas.add_keyword": "新規キーワード",
"antennas.add_keyword_placeholder": "新しいキーワード",
"antennas.add_tag": "新規タグ",
"antennas.add_tag_placeholder": "新しいタグ",
"antennas.delete": "アンテナを削除",
"antennas.domains": "{count} のドメイン",
"antennas.edit": "アンテナを編集",
@ -104,6 +108,7 @@
"antennas.exclude_accounts": "除外するアカウント",
"antennas.exclude_domains": "除外するドメイン",
"antennas.exclude_keywords": "除外するキーワード",
"antennas.exclude_tags": "除外するタグ",
"antennas.filter": "絞り込み条件",
"antennas.filter_not": "絞り込み条件の例外",
"antennas.go_timeline": "タイムラインを見る",
@ -141,6 +146,13 @@
"bundle_modal_error.close": "閉じる",
"bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。",
"bundle_modal_error.retry": "再試行",
"circles.account.add": "おはぎに追加",
"circles.account.remove": "おはぎから外す",
"circles.edit.submit": "タイトルを変更",
"circles.new.create": "サークルを作成",
"circles.new.title_placeholder": "新規サークル名",
"circles.search": "フォロワーの中から検索",
"circles.subheading": "あなたのサークル",
"closed_registrations.other_server_instructions": "Mastodonは分散型なので他のサーバーにアカウントを作ってもこのサーバーとやり取りできます。",
"closed_registrations_modal.description": "現在{domain}でアカウント作成はできませんがMastodonは{domain}のアカウントでなくても利用できます。",
"closed_registrations_modal.find_another_server": "別のサーバーを探す",
@ -150,6 +162,7 @@
"column.antennas": "アンテナ",
"column.blocks": "ブロックしたユーザー",
"column.bookmarks": "ブックマーク",
"column.circles": "サークル",
"column.community": "ローカルタイムライン",
"column.direct": "非公開の返信",
"column.directory": "ディレクトリ",
@ -215,6 +228,8 @@
"confirmations.cancel_follow_request.message": "{name}に対するフォローリクエストを取り消しますか?",
"confirmations.delete.confirm": "削除",
"confirmations.delete.message": "本当に削除しますか?",
"confirmations.delete_circle.confirm": "削除",
"confirmations.delete_circle.message": "本当にこのサークルを完全に削除しますか?",
"confirmations.delete_list.confirm": "削除",
"confirmations.delete_list.message": "本当にこのリストを完全に削除しますか?",
"confirmations.delete_antenna.confirm": "削除",
@ -451,6 +466,7 @@
"navigation_bar.antennas": "アンテナ",
"navigation_bar.blocks": "ブロックしたユーザー",
"navigation_bar.bookmarks": "ブックマーク",
"navigation_bar.circles": "サークル",
"navigation_bar.community_timeline": "ローカルタイムライン",
"navigation_bar.compose": "投稿の新規作成",
"navigation_bar.direct": "非公開の返信",
@ -570,6 +586,8 @@
"poll_button.add_poll": "アンケートを追加",
"poll_button.remove_poll": "アンケートを削除",
"privacy.change": "公開範囲を変更",
"privacy.circle.long": "サークルメンバーのみ",
"privacy.circle.short": "サークル",
"privacy.direct.long": "指定された相手のみ閲覧可",
"privacy.direct.short": "指定された相手のみ",
"privacy.limited.short": "限定投稿",

View file

@ -6,8 +6,13 @@ import {
ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST,
ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS,
ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS,
ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS,
} from '../actions/antennas';
const initialState = ImmutableMap({
@ -29,18 +34,23 @@ export default function antennaAdderReducer(state = initialState, action) {
map.set('accountId', action.account.get('id'));
});
case ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST:
case ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST:
return state.setIn(['antennas', 'isLoading'], true);
case ANTENNA_ADDER_ANTENNAS_FETCH_FAIL:
return state.setIn(['antennas', 'isLoading'], false);
case ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL:
return state.setIn(['antennas', 'isLoading'], false);
case ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS:
return state.update('antennas', antennas => antennas.withMutations(map => {
case ANTENNA_ADDER_EXCLUDE_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_SUCCESS:
case ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId));
case ANTENNA_EDITOR_REMOVE_SUCCESS:
case ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId));
default:
return state;

View file

@ -38,12 +38,6 @@ const initialState = ImmutableMap({
isLoading: false,
}),
excludeAccounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
@ -92,11 +86,11 @@ export default function antennaEditorReducer(state = initialState, action) {
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
case ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['excludeAccounts', 'isLoading'], true);
return state.setIn(['accounts', 'isLoading'], true);
case ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL:
return state.setIn(['excludeAccounts', 'isLoading'], false);
return state.setIn(['accounts', 'isLoading'], false);
case ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS:
return state.update('excludeAccounts', accounts => accounts.withMutations(map => {
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)));
@ -111,13 +105,11 @@ export default function antennaEditorReducer(state = initialState, action) {
map.set('value', '');
}));
case ANTENNA_EDITOR_ADD_SUCCESS:
case ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.unshift(action.accountId));
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.filterNot(item => item === action.accountId));
case ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS:
return state.updateIn(['excludeAccounts', 'items'], antenna => antenna.unshift(action.accountId));
case ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS:
return state.updateIn(['excludeAccounts', 'items'], antenna => antenna.filterNot(item => item === action.accountId));
return state.updateIn(['accounts', 'items'], antenna => antenna.filterNot(item => item === action.accountId));
default:
return state;
}

View file

@ -1,15 +1,35 @@
input,
input[type='text'],
input[type='search'],
input[type='number'],
input:not([type]),
textarea {
background: $ui-base-color !important;
color: $primary-text-color !important;
&.setting-text {
border-color: $darker-text-color;
}
&::placeholder {
color: lighten($dark-text-color, 36%) !important;
}
}
.column-content-select__control,
.button.button-secondary {
border-color: $darker-text-color !important;
}
.emoji-mart-category-label {
color: $lighter-text-color !important;
color: $primary-text-color !important;
}
.emoji-mart-bar:first-child {
background: lighten($classic-base-color, 36%);
}
.emoji-mart-anchor-selected {
color: $primary-text-color;
}
.compose-form .compose-form__warning {

View file

@ -6,9 +6,12 @@ class ActivityPub::FetchInstanceInfoWorker
include Redisable
include Lockable
sidekiq_options queue: 'push', retry: 2
class Error < StandardError; end
class GoneError < Error; end
class RequestError < Error; end
class DeadError < Error; end
SUPPORTED_NOTEINFO_RELS = ['http://nodeinfo.diaspora.software/ns/schema/2.0', 'http://nodeinfo.diaspora.software/ns/schema/2.1'].freeze
@ -22,6 +25,8 @@ class ActivityPub::FetchInstanceInfoWorker
update_info!(link)
end
rescue ActivityPub::FetchInstanceInfoWorker::DeadError
true
end
private
@ -65,6 +70,8 @@ class ActivityPub::FetchInstanceInfoWorker
body_to_json(response.body_with_limit)
elsif response.code == 410
raise ActivityPub::FetchInstanceInfoWorker::GoneError, "#{@instance.domain} is gone from the server"
elsif response.code == 404
raise ActivityPub::FetchInstanceInfoWorker::DeadError, "Request for #{@instance.domain} returned HTTP #{response.code}"
else
raise ActivityPub::FetchInstanceInfoWorker::RequestError, "Request for #{@instance.domain} returned HTTP #{response.code}"
end

View file

@ -249,6 +249,7 @@ ja:
setting_hide_statuses_count: 投稿数を隠す
setting_stay_privacy: 投稿時に公開範囲を保存する
setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する
setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する
setting_reduce_motion: アニメーションの動きを減らす
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する
setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する

View file

@ -180,6 +180,7 @@ namespace :api, format: false do
resources :following, only: :index, controller: 'accounts/following_accounts'
resources :lists, only: :index, controller: 'accounts/lists'
resources :antennas, only: :index, controller: 'accounts/antennas'
resources :exclude_antennas, only: :index, controller: 'accounts/exclude_antennas'
resources :circles, only: :index, controller: 'accounts/circles'
resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
resources :featured_tags, only: :index, controller: 'accounts/featured_tags'

View file

@ -21,7 +21,7 @@ module Mastodon
end
def suffix
ENV.fetch('MASTODON_VERSION_SUFFIX', '')
ENV.fetch('MASTODON_VERSION_SUFFIX', '+kmyblue')
end
def to_a

View file

@ -1188,5 +1188,54 @@ RSpec.describe ActivityPub::Activity::Create do
expect(sender.statuses.count).to eq 0
end
end
context 'when bearcaps' do
subject { described_class.new(json, sender) }
before do
stub_request(:get, 'https://example.com/statuses/1234567890')
.with(headers: { 'Authorization' => 'Bearer test_ohagi_token' })
.to_return(status: 200, body: Oj.dump(object_json), headers: {})
subject.perform
end
let!(:recipient) { Fabricate(:account) }
let(:object_json) do
{
id: 'https://example.com/statuses/1234567890',
type: 'Note',
content: 'Lorem ipsum',
to: ActivityPub::TagManager.instance.uri_for(recipient),
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
},
],
}
end
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: "bear:?#{{ u: 'https://example.com/statuses/1234567890', t: 'test_ohagi_token' }.to_query}",
}.with_indifferent_access
end
it 'creates status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.text).to eq 'Lorem ipsum'
expect(status.mentions.map(&:account)).to include(recipient)
expect(status.mentions.count).to eq 1
expect(status.visibility).to eq 'limited'
expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png')
end
end
end
end

View file

@ -12,6 +12,59 @@ describe StatusReachFinder do
let(:alice) { Fabricate(:account, username: 'alice') }
let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) }
context 'with a simple case' do
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
context 'with follower' do
before do
bob.follow!(alice)
end
it 'send status' do
expect(subject.inboxes).to include 'https://foo.bar/inbox'
end
end
context 'with non-follower' do
it 'send status' do
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
end
end
end
context 'when misskey case with unlisted post' do
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
let(:sender_software) { 'mastodon' }
let(:visibility) { :unlisted }
before do
Fabricate(:instance_info, domain: 'foo.bar', software: sender_software)
bob.follow!(alice)
end
context 'when mastodon' do
it 'send status' do
expect(subject.inboxes).to include 'https://foo.bar/inbox'
expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox'
end
end
context 'when misskey' do
let(:sender_software) { 'misskey' }
it 'send status without setting' do
expect(subject.inboxes).to include 'https://foo.bar/inbox'
expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox'
end
it 'send status with setting' do
alice.user.settings.update(reject_unlisted_subscription: 'true')
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
expect(subject.inboxes_for_misskey).to include 'https://foo.bar/inbox'
end
end
end
context 'when it contains mentions of remote accounts' do
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'rails_helper'
describe ActivityPub::NoteForMisskeySerializer do
subject { JSON.parse(serialization.to_json) }
let(:serialization) { ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) }
let!(:account) { Fabricate(:account) }
let!(:other) { Fabricate(:account) }
let!(:parent) { Fabricate(:status, account: account, visibility: :unlisted) }
let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) }
let!(:reply_by_account_third) { Fabricate(:status, account: account, thread: parent, visibility: :public) }
let!(:reply_by_account_visibility_direct) { Fabricate(:status, account: account, thread: parent, visibility: :direct) }
before do
account.user.settings.update(reject_unlisted_subscription: 'true')
end
it 'has a Note type' do
expect(subject['type']).to eql('Note')
end
it 'has a replies collection' do
expect(subject['replies']['type']).to eql('Collection')
end
it 'has a replies collection with a first Page' do
expect(subject['replies']['first']['type']).to eql('CollectionPage')
end
it 'includes public self-replies in its replies collection' do
expect(subject['replies']['first']['items']).to include(reply_by_account_first.uri, reply_by_account_next.uri, reply_by_account_third.uri)
end
it 'does not include replies from others in its replies collection' do
expect(subject['replies']['first']['items']).to_not include(reply_by_other_first.uri)
end
it 'does not include replies with direct visibility in its replies collection' do
expect(subject['replies']['first']['items']).to_not include(reply_by_account_visibility_direct.uri)
end
it 'has private visibility' do
expect(subject['to']).to_not include('https://www.w3.org/ns/activitystreams#Public')
expect(subject['to'].any? { |to| to.end_with?("#{account.username}/followers") }).to be true
expect(subject['cc']).to_not include('https://www.w3.org/ns/activitystreams#Public')
end
end

View file

@ -88,7 +88,7 @@ describe ActivityPub::FetchInstanceInfoWorker do
end
it 'performs a mastodon instance' do
expect { subject.perform('example.com') }.to raise_error(ActivityPub::FetchInstanceInfoWorker::RequestError, 'Request for example.com returned HTTP 404')
expect(subject.perform('example.com')).to be true
info = InstanceInfo.find_by(domain: 'example.com')
expect(info).to be_nil