Merge commit '322ce1fc27' into kb_patch

This commit is contained in:
KMY 2023-10-03 21:31:18 +09:00
commit 1ac499aefe
102 changed files with 1827 additions and 495 deletions

4
.github/FUNDING.yml vendored
View file

@ -1,3 +1 @@
patreon: mastodon custom: https://fantia.jp/fanclubs/484677
open_collective: mastodon
custom: https://sponsor.joinmastodon.org

74
.github/ISSUE_TEMPLATE/1.bug_report.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: バグ報告
description: kmyblueのバグ報告
labels: [bug]
body:
- type: textarea
attributes:
label: バグの再現手順
description: どのように操作したらバグが発生したのか、バグが発生する直前までの手順を順番に詳しく教えてください
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: 期待する動作
description: どのように動いてほしかったですか?
validations:
required: true
- type: textarea
attributes:
label: 実際の動作
description: どのようなバグが発生しましたか?
validations:
required: true
- type: textarea
attributes:
label: 詳しい情報
validations:
required: false
- type: input
attributes:
label: バグが発生したkmyblueサーバーのドメイン
description: サーバー固有の問題の可能性もありますので、プライバシー上可能な範囲内で、できるだけ書いてください
placeholder: kmy.blue
validations:
required: false
- type: input
attributes:
label: バグが発生したkmyblueのバージョン
description: |
Mastodonではなくkmyblueのバージョンを記述してください。例えばバージョン表記が `v4.2.0+kmyblue.5.1-LTS` の場合、バージョンは `5.1`になります
バージョンは、PCだと画面左下、スマホだと概要画面の一番下に書いてあります
placeholder: '5.1'
validations:
required: true
- type: input
attributes:
label: ブラウザの名前
description: |
ブラウザの名前を書いてください。可能であればバージョンも併記してください
placeholder: Firefox 105.0.3
validations:
required: false
- type: input
attributes:
label: OS
description: |
あなたのOSと、できればバージョンも教えてください。スマホの場合は、「Android」「iPhone」にバージョンをつけてください
placeholder: Windows11
validations:
required: false
- type: textarea
attributes:
label: その他の詳細情報
description: |
あなたの環境が特殊な場合、詳しいことを教えてください(例: VPS、tor、学内LANなど
サーバー管理者の場合は、Ruby、Node.jsのバージョン、Cloudflareの使用可否なども可能なら書いてください
validations:
required: false

View file

@ -1,76 +0,0 @@
name: Bug Report (Web Interface)
description: If you are using Mastodon's web interface and something is not working as expected
labels: [bug, 'status/to triage', 'area/web interface']
body:
- type: markdown
attributes:
value: |
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: input
attributes:
label: Expected behaviour
description: What should have happened?
validations:
required: true
- type: input
attributes:
label: Actual behaviour
description: What happened?
validations:
required: true
- type: textarea
attributes:
label: Detailed description
validations:
required: false
- type: input
attributes:
label: Mastodon instance
description: The address of the Mastodon instance where you experienced the issue
placeholder: mastodon.social
validations:
required: true
- type: input
attributes:
label: Mastodon version
description: |
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627`
placeholder: v4.1.2
validations:
required: true
- type: input
attributes:
label: Browser name and version
description: |
What browser are you using when getting this bug? Please specify the version as well.
placeholder: Firefox 105.0.3
validations:
required: true
- type: input
attributes:
label: Operating system
description: |
What OS are you running? Please specify the version as well.
placeholder: macOS 13.4.1
validations:
required: true
- type: textarea
attributes:
label: Technical details
description: |
Any additional technical details you may have. This can include the full error log, inspector's output…
validations:
required: false

View file

@ -0,0 +1,16 @@
name: 機能要望
description: 機能の提案
labels: [enhancement]
body:
- type: textarea
attributes:
label: 欲しい機能
description: 欲しい機能の詳細を書いてください
validations:
required: true
- type: textarea
attributes:
label: 必要性
description: この機能はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか?
validations:
required: true

View file

@ -1,65 +0,0 @@
name: Bug Report (server / API)
description: |
If something is not working as expected, but is not from using the web interface.
labels: [bug, 'status/to triage']
body:
- type: markdown
attributes:
value: |
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: input
attributes:
label: Expected behaviour
description: What should have happened?
validations:
required: true
- type: input
attributes:
label: Actual behaviour
description: What happened?
validations:
required: true
- type: textarea
attributes:
label: Detailed description
validations:
required: false
- type: input
attributes:
label: Mastodon instance
description: The address of the Mastodon instance where you experienced the issue
placeholder: mastodon.social
validations:
required: false
- type: input
attributes:
label: Mastodon version
description: |
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627`
placeholder: v4.1.2
validations:
required: false
- type: textarea
attributes:
label: Technical details
description: |
Any additional technical details you may have, like logs or error traces
value: |
If this is happening on your own Mastodon server, please fill out those:
- Ruby version: (from `ruby --version`, eg. v3.1.2)
- Node.js version: (from `node --version`, eg. v18.16.0)
validations:
required: false

View file

@ -1,22 +0,0 @@
name: Feature Request
description: I have a suggestion
labels: [suggestion]
body:
- type: markdown
attributes:
value: |
Please use a concise and distinct title for the issue.
Consider: Could it be implemented as a 3rd party app using the REST API instead?
- type: textarea
attributes:
label: Pitch
description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: Why do you think this feature is needed? Who would benefit from it?
validations:
required: true

View file

@ -0,0 +1,28 @@
name: 仕様変更・改善要望
description: 既存の仕様や挙動変更の要望
labels: [specchange]
body:
- type: markdown
attributes:
value: 意図したものとは明らかに異なる挙動をしているものはバグとして、もともと仕様として決められた動きをしているものを変更したいときはこちらでお願いします
- type: textarea
attributes:
label: 挙動を変更してほしい機能や動作
validations:
required: true
- type: textarea
attributes:
label: 現在の挙動
validations:
required: true
- type: textarea
attributes:
label: 変更してほしい新しい挙動
validations:
required: true
- type: textarea
attributes:
label: 必要性
description: この変更はあなたにとってなぜ必要でしょうか?どういった状況で使われるものですか?
validations:
required: true

View file

@ -1,5 +1 @@
blank_issues_enabled: false blank_issues_enabled: true
contact_links:
- name: GitHub Discussions
url: https://github.com/mastodon/mastodon/discussions
about: Please ask and answer questions here.

View file

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
# This needs to be bookworm-slim because the Ruby image is built on bookworm-slim # This needs to be bookworm-slim because the Ruby image is built on bookworm-slim
ARG NODE_VERSION="20.6-bookworm-slim" ARG NODE_VERSION="20.7-bookworm-slim"
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby
FROM node:${NODE_VERSION} as build FROM node:${NODE_VERSION} as build

View file

@ -688,7 +688,7 @@ GEM
scenic (1.7.0) scenic (1.7.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.11.0) selenium-webdriver (4.13.1)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
@ -806,7 +806,7 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
websocket (1.2.9) websocket (1.2.10)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)

View file

@ -37,16 +37,34 @@ RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/sear
## kmyblueの強み ## kmyblueの強み
追加の詳細は下記記事もご覧ください。
https://note.com/kmycode/n/n5fd5e823ed40
### 本家Mastodonへの積極的追従 ### 本家Mastodonへの積極的追従
kmyblueは、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。 kmyblueは、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。
kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。 kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。
### ゆるやかな内輪での運用
kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪リを外部にできるだけもらさない」という部分に特化しています。
「ローカル公開」という機能によって、「ローカルタイムラインに流すが他のサーバーの連合タイムラインに流さない」投稿が可能です。ただしMisskeyのローカル限定とは異なり、他のサーバーのフォロワーのタイムラインにも投稿は流れます。自分のサーバーの中で内輪で盛り上がって、他のサーバーの連合タイムラインには外面だけの投稿を流すことも可能です。
また、通常のMastodonでは公開投稿を他のサーバーの人に自由に検索できるようにすることも可能ですが、kmyblueでは未収載投稿に対して同様の設定が可能です。つまり、ローカルタイムラインにも連合タイムラインにも流れない、誰かの目に自然に触れることはない、でも特定キーワードを使った検索では引っかかりたい、そのような需要に対応できます。ただしこの検索ができるのはMisskeyならびにkmyblueフォークだけです。
### 絵文字リアクション対応 ### 絵文字リアクション対応
kmyblueは絵文字リアクションに対応しているフォークのつです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。絵文字リアクションによってユーザーは「お気に入り」以上「返信」以下のコミュニケーションを気軽に行うことができ、Mastodonの利用体験が向上します。 kmyblueは絵文字リアクションに対応しているフォークのつです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。絵文字リアクションによってユーザーは「お気に入り」以上「返信」以下のコミュニケーションを気軽に行うことができ、Mastodonの利用体験が向上します。
各ユーザーが自分の投稿に絵文字リアクションをつけることを拒否できるほか、サーバー全体として絵文字リアクションを無効にする設定も可能です(この場合、他サーバーから来た絵文字リアクションはお気に入りとして保存されます) 各ユーザーが自分の投稿に絵文字リアクションをつけることを拒否できるほか、サーバー全体として絵文字リアクションを無効にする設定も可能です(この場合、他サーバーから来た絵文字リアクションはお気に入りとして保存されます)
### プライバシーへの配慮
- **ローカル公開** - ローカルタイムラインにのみ投稿を流し、他サーバーの連合タイムラインに流しません。他のサーバーには未収載として配信されます
- **検索許可** - 投稿ごとに検索を許可する範囲を細かく制御できます。これは本家Mastodonにはない特徴です
- **Misskeyへの投稿配送制限** - Misskeyへ未収載投稿を配送する時、「フォロワーのみ」に変換する設定がユーザー個別に可能です。Misskeyの自由な検索からkmyblue上の投稿を保護します
## kmyblueのブランチ ## kmyblueのブランチ
- **main** - 管理者が本家MastodonにPRするときに使うことがあります - **main** - 管理者が本家MastodonにPRするときに使うことがあります
@ -58,7 +76,9 @@ kmyblueは絵文字リアクションに対応しているフォークの
## 本家Mastodonからの追加機能 ## 本家Mastodonからの追加機能
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。 kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。ただし以下はあくまで一例です。ほぼ完全な一覧は、以下の記事を参照してください。
https://note.com/kmycode/n/n5fd5e823ed40
### ローカル公開 ### ローカル公開

View file

@ -0,0 +1,65 @@
# frozen_string_literal: true
class Api::V1::Circles::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action :require_user!
before_action :set_circle
after_action :insert_pagination_headers, only: :show
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
private
def set_circle
@circle = current_account.circles.find(params[:circle_id])
end
def load_statuses
if unlimited?
@circle.statuses.includes(:status_stat).all
else
@circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
end
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_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,74 @@
# frozen_string_literal: true
class Api::V1::Statuses::MentionedAccountsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
before_action :set_status
after_action :insert_pagination_headers
def index
cache_if_unauthenticated!
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
scope.merge(paginated_mentioned_users).to_a
end
def default_accounts
Account
.without_suspended
.includes(:mentions, :account_stat)
.references(:mentions)
.where(mentions: { status_id: @status.id })
end
def paginated_mentioned_users
Mention.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end
def pagination_max_id
@accounts.last.mentions.last.id
end
def pagination_since_id
@accounts.first.mentions.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show_mentioned_users?
rescue Mastodon::NotPermittedError
not_found
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View file

@ -1,7 +1,7 @@
import api from '../api'; import api, { getLinks } from '../api';
import { showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts, importFetchedStatuses } from './importer';
export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST';
export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS';
@ -50,6 +50,14 @@ export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_RE
export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS';
export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL';
export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST';
export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS';
export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL';
export const CIRCLE_STATUSES_EXPAND_REQUEST = 'CIRCLE_STATUSES_EXPAND_REQUEST';
export const CIRCLE_STATUSES_EXPAND_SUCCESS = 'CIRCLE_STATUSES_EXPAND_SUCCESS';
export const CIRCLE_STATUSES_EXPAND_FAIL = 'CIRCLE_STATUSES_EXPAND_FAIL';
export const fetchCircle = id => (dispatch, getState) => { export const fetchCircle = id => (dispatch, getState) => {
if (getState().getIn(['circles', id])) { if (getState().getIn(['circles', id])) {
return; return;
@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => {
dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId'])));
}; };
export function fetchCircleStatuses(circleId) {
return (dispatch, getState) => {
if (getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) {
return;
}
dispatch(fetchCircleStatusesRequest(circleId));
api(getState).get(`/api/v1/circles/${circleId}/statuses`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(fetchCircleStatusesSuccess(circleId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchCircleStatusesFail(circleId, error));
});
};
}
export function fetchCircleStatusesRequest(id) {
return {
type: CIRCLE_STATUSES_FETCH_REQUEST,
id,
};
}
export function fetchCircleStatusesSuccess(id, statuses, next) {
return {
type: CIRCLE_STATUSES_FETCH_SUCCESS,
id,
statuses,
next,
};
}
export function fetchCircleStatusesFail(id, error) {
return {
type: CIRCLE_STATUSES_FETCH_FAIL,
id,
error,
};
}
export function expandCircleStatuses(circleId) {
return (dispatch, getState) => {
const url = getState().getIn(['circles', circleId, 'statuses', 'next'], null);
if (url === null || getState().getIn(['circles', circleId, 'statuses', 'isLoading'])) {
return;
}
dispatch(expandCircleStatusesRequest(circleId));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandCircleStatusesSuccess(circleId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandCircleStatusesFail(circleId, error));
});
};
}
export function expandCircleStatusesRequest(id) {
return {
type: CIRCLE_STATUSES_EXPAND_REQUEST,
id,
};
}
export function expandCircleStatusesSuccess(id, statuses, next) {
return {
type: CIRCLE_STATUSES_EXPAND_SUCCESS,
id,
statuses,
next,
};
}
export function expandCircleStatusesFail(id, error) {
return {
type: CIRCLE_STATUSES_EXPAND_FAIL,
id,
error,
};
}

View file

@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_RESET = 'COMPOSE_RESET';
export const COMPOSE_WITH_CIRCLE_SUCCESS = 'COMPOSE_WITH_CIRCLE_SUCCESS';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
@ -174,6 +176,7 @@ export function submitCompose(routerHistory) {
const status = getState().getIn(['compose', 'text'], ''); const status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const statusId = getState().getIn(['compose', 'id'], null); const statusId = getState().getIn(['compose', 'id'], null);
const circleId = getState().getIn(['compose', 'circle_id'], null);
if ((!status || !status.length) && media.size === 0) { if ((!status || !status.length) && media.size === 0) {
return; return;
@ -253,6 +256,10 @@ export function submitCompose(routerHistory) {
insertIfOnline(`account:${response.data.account.id}`); insertIfOnline(`account:${response.data.account.id}`);
} }
if (statusId === null && circleId !== null && circleId !== 0) {
dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId));
}
dispatch(showAlert({ dispatch(showAlert({
message: statusId === null ? messages.published : messages.saved, message: statusId === null ? messages.published : messages.saved,
action: messages.open, action: messages.open,
@ -278,6 +285,14 @@ export function submitComposeSuccess(status) {
}; };
} }
export function submitComposeWithCircleSuccess(status, circleId) {
return {
type: COMPOSE_WITH_CIRCLE_SUCCESS,
status,
circleId,
}
}
export function submitComposeFail(error) { export function submitComposeFail(error) {
return { return {
type: COMPOSE_SUBMIT_FAIL, type: COMPOSE_SUBMIT_FAIL,

View file

@ -1,10 +0,0 @@
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
export function openDropdownMenu(id, keyboard, scroll_key) {
return { type: DROPDOWN_MENU_OPEN, id, keyboard, scroll_key };
}
export function closeDropdownMenu(id) {
return { type: DROPDOWN_MENU_CLOSE, id };
}

View file

@ -0,0 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
export const openDropdownMenu = createAction<{
id: string;
keyboard: boolean;
scrollKey: string;
}>('dropdownMenu/open');
export const closeDropdownMenu = createAction<{ id: string }>(
'dropdownMenu/close',
);

View file

@ -71,6 +71,14 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export const MENTIONED_USERS_FETCH_REQUEST = 'MENTIONED_USERS_FETCH_REQUEST';
export const MENTIONED_USERS_FETCH_SUCCESS = 'MENTIONED_USERS_FETCH_SUCCESS';
export const MENTIONED_USERS_FETCH_FAIL = 'MENTIONED_USERS_FETCH_FAIL';
export const MENTIONED_USERS_EXPAND_REQUEST = 'MENTIONED_USERS_EXPAND_REQUEST';
export const MENTIONED_USERS_EXPAND_SUCCESS = 'MENTIONED_USERS_EXPAND_SUCCESS';
export const MENTIONED_USERS_EXPAND_FAIL = 'MENTIONED_USERS_EXPAND_FAIL';
export function reblog(status, visibility) { export function reblog(status, visibility) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
@ -735,3 +743,85 @@ export function unpinFail(status, error) {
skipLoading: true, skipLoading: true,
}; };
} }
export function fetchMentionedUsers(id) {
return (dispatch, getState) => {
dispatch(fetchMentionedUsersRequest(id));
api(getState).get(`/api/v1/statuses/${id}/mentioned_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchMentionedUsersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchMentionedUsersFail(id, error));
});
};
}
export function fetchMentionedUsersRequest(id) {
return {
type: MENTIONED_USERS_FETCH_REQUEST,
id,
};
}
export function fetchMentionedUsersSuccess(id, accounts, next) {
return {
type: MENTIONED_USERS_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchMentionedUsersFail(id, error) {
return {
type: MENTIONED_USERS_FETCH_FAIL,
id,
error,
};
}
export function expandMentionedUsers(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'mentioned_users', id, 'next']);
if (url === null) {
return;
}
dispatch(expandMentionedUsersRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandMentionedUsersSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandMentionedUsersFail(id, error)));
};
}
export function expandMentionedUsersRequest(id) {
return {
type: MENTIONED_USERS_EXPAND_REQUEST,
id,
};
}
export function expandMentionedUsersSuccess(id, accounts, next) {
return {
type: MENTIONED_USERS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandMentionedUsersFail(id, error) {
return {
type: MENTIONED_USERS_EXPAND_FAIL,
id,
error,
};
}

View file

@ -1,12 +1,14 @@
import { createAction } from '@reduxjs/toolkit'; import { createAction } from '@reduxjs/toolkit';
import type { ModalProps } from 'mastodon/reducers/modal';
import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root'; import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root';
export type ModalType = keyof typeof MODAL_COMPONENTS; export type ModalType = keyof typeof MODAL_COMPONENTS;
interface OpenModalPayload { interface OpenModalPayload {
modalType: ModalType; modalType: ModalType;
modalProps: unknown; modalProps: ModalProps;
} }
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN'); export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');

View file

@ -0,0 +1,65 @@
import type { ApiCustomEmojiJSON } from './custom_emoji';
export interface ApiAccountFieldJSON {
name: string;
value: string;
verified_at: string | null;
}
export interface ApiAccountRoleJSON {
color: string;
id: string;
name: string;
}
export interface ApiAccountOtherSettingsJSON {
noindex: boolean;
noai: boolean;
hide_network: boolean;
hide_statuses_count: boolean;
hide_following_count: boolean;
hide_followers_count: boolean;
translatable_private: boolean;
link_preview: boolean;
emoji_reaction_policy?:
| 'allow'
| 'outside_only'
| 'following_only'
| 'followers_only'
| 'mutuals_only'
| 'block';
}
// See app/serializers/rest/account_serializer.rb
export interface ApiAccountJSON {
acct: string;
avatar: string;
avatar_static: string;
bot: boolean;
created_at: string;
discoverable: boolean;
display_name: string;
emojis: ApiCustomEmojiJSON[];
fields: ApiAccountFieldJSON[];
followers_count: number;
following_count: number;
group: boolean;
header: string;
header_static: string;
id: string;
last_status_at: string;
locked: boolean;
noindex: boolean;
note: string;
other_settings: ApiAccountOtherSettingsJSON;
roles: ApiAccountJSON[];
subscribable: boolean;
statuses_count: number;
uri: string;
url: string;
username: string;
moved?: ApiAccountJSON;
suspended?: boolean;
limited?: boolean;
memorial?: boolean;
}

View file

@ -0,0 +1,12 @@
// See app/serializers/rest/account_serializer.rb
export interface ApiCustomEmojiJSON {
shortcode: string;
static_url: string;
url: string;
category?: string;
visible_in_picker: boolean;
width?: number;
height?: number;
sensitive?: boolean;
aliases?: string[];
}

View file

@ -0,0 +1,18 @@
// See app/serializers/rest/relationship_serializer.rb
export interface ApiRelationshipJSON {
blocked_by: boolean;
blocking: boolean;
domain_blocking: boolean;
endorsed: boolean;
followed_by: boolean;
following: boolean;
id: string;
languages: string[] | null;
muting_notifications: boolean;
muting: boolean;
note: string;
notifying: boolean;
requested_by: boolean;
requested: boolean;
showing_reblogs: boolean;
}

View file

@ -4,9 +4,14 @@ import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_m
import { fetchHistory } from 'mastodon/actions/history'; import { fetchHistory } from 'mastodon/actions/history';
import DropdownMenu from 'mastodon/components/dropdown_menu'; import DropdownMenu from 'mastodon/components/dropdown_menu';
/**
*
* @param {import('mastodon/store').RootState} state
* @param {*} props
*/
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.dropdownMenu.keyboard,
items: state.getIn(['history', statusId, 'items']), items: state.getIn(['history', statusId, 'items']),
loading: state.getIn(['history', statusId, 'loading']), loading: state.getIn(['history', statusId, 'loading']),
}); });
@ -15,11 +20,11 @@ const mapDispatchToProps = (dispatch, { statusId }) => ({
onOpen (id, onItemClick, keyboard) { onOpen (id, onItemClick, keyboard) {
dispatch(fetchHistory(statusId)); dispatch(fetchHistory(statusId));
dispatch(openDropdownMenu(id, keyboard)); dispatch(openDropdownMenu({ id, keyboard }));
}, },
onClose (id) { onClose (id) {
dispatch(closeDropdownMenu(id)); dispatch(closeDropdownMenu({ id }));
}, },
}); });

View file

@ -23,9 +23,14 @@ const MOUSE_IDLE_DELAY = 300;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
/**
*
* @param {import('mastodon/store').RootState} state
* @param {*} props
*/
const mapStateToProps = (state, { scrollKey }) => { const mapStateToProps = (state, { scrollKey }) => {
return { return {
preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']), preventScroll: scrollKey === state.dropdownMenu.scrollKey,
}; };
}; };

View file

@ -73,6 +73,7 @@ const messages = defineMessages({
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
}); });
@ -405,6 +406,7 @@ class Status extends ImmutablePureComponent {
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
}; };

View file

@ -24,6 +24,7 @@ const messages = defineMessages({
edit: { id: 'status.edit', defaultMessage: 'Edit' }, edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
@ -249,6 +250,10 @@ class StatusActionBar extends ImmutablePureComponent {
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`); this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
}; };
handleOpenMentions = () => {
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`);
};
handleEmbed = () => { handleEmbed = () => {
this.props.onEmbed(this.props.status); this.props.onEmbed(this.props.status);
}; };
@ -315,7 +320,11 @@ class StatusActionBar extends ImmutablePureComponent {
} }
if (signedIn) { if (signedIn) {
if (!simpleTimelineMenu) { if (writtenByMe) {
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
}
if (!simpleTimelineMenu || writtenByMe) {
menu.push(null); menu.push(null);
} }

View file

@ -7,9 +7,12 @@ import { openModal, closeModal } from '../actions/modal';
import DropdownMenu from '../components/dropdown_menu'; import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile'; import { isUserTouching } from '../is_mobile';
/**
* @param {import('mastodon/store').RootState} state
*/
const mapStateToProps = state => ({ const mapStateToProps = state => ({
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.dropdownMenu.openId,
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.dropdownMenu.keyboard,
}); });
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
@ -25,7 +28,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
actions: items, actions: items,
onClick: onItemClick, onClick: onItemClick,
}, },
}) : openDropdownMenu(id, keyboard, scrollKey)); }) : openDropdownMenu({ id, keyboard, scrollKey }));
}, },
onClose(id) { onClose(id) {
@ -33,7 +36,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
modalType: 'ACTIONS', modalType: 'ACTIONS',
ignoreFocus: false, ignoreFocus: false,
})); }));
dispatch(closeDropdownMenu(id)); dispatch(closeDropdownMenu({ id }));
}, },
}); });

View file

@ -28,6 +28,11 @@ const messages = defineMessages({
silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' }, silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' }, suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' },
suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' }, suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' },
publicUnlistedVisibility: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' },
enabled: { id: 'about.enabled', defaultMessage: 'Enabled' },
disabled: { id: 'about.disabled', defaultMessage: 'Disabled' },
capabilities: { id: 'about.kmyblue_capabilities', defaultMessage: 'kmyblue capabilities' },
}); });
const severityMessages = { const severityMessages = {
@ -122,6 +127,10 @@ class About extends PureComponent {
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props; const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const isLoading = server.get('isLoading'); const isLoading = server.get('isLoading');
const fedibirdCapabilities = server.get('fedibird_capabilities');
const isPublicUnlistedVisibility = fedibirdCapabilities.includes('kmyblue_visibility_public_unlisted');
const isEmojiReaction = fedibirdCapabilities.includes('emoji_reaction');
return ( return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable about'> <div className='scrollable about'>
@ -182,6 +191,20 @@ class About extends PureComponent {
))} ))}
</Section> </Section>
<Section title={intl.formatMessage(messages.capabilities)}>
<p><FormattedMessage id='about.kmyblue_capability' defaultMessage='This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.' /></p>
{!isLoading && (
<ol className='rules-list'>
<li>
<span className='rules-list__text'>{intl.formatMessage(messages.emojiReaction)}: {intl.formatMessage(isEmojiReaction ? messages.enabled : messages.disabled)}</span>
</li>
<li>
<span className='rules-list__text'>{intl.formatMessage(messages.publicUnlistedVisibility)}: {intl.formatMessage(isPublicUnlistedVisibility ? messages.enabled : messages.disabled)}</span>
</li>
</ol>
)}
</Section>
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}> <Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
{domainBlocks.get('isLoading') ? ( {domainBlocks.get('isLoading') ? (
<> <>

View file

@ -0,0 +1,182 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { deleteCircle, expandCircleStatuses, fetchCircle, fetchCircleStatuses } from 'mastodon/actions/circles';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { getCircleStatusList } from 'mastodon/selectors';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
heading: { id: 'column.circles', defaultMessage: 'Circles' },
});
const mapStateToProps = (state, { params }) => ({
circle: state.getIn(['circles', params.id]),
statusIds: getCircleStatusList(state, params.id),
isLoading: state.getIn(['circles', params.id, 'isLoading'], true),
isEditing: state.getIn(['circleEditor', 'circleId']) === params.id,
hasMore: !!state.getIn(['circles', params.id, 'next']),
});
class CircleStatuses extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
circle: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircle(this.props.params.id));
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('CIRCLE_STATUSES', { id: this.props.params.id }));
this.context.router.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'CIRCLE_EDITOR',
modalProps: { circleId: this.props.params.id },
}));
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.context.router.history.push('/circles');
}
},
},
}));
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandCircleStatuses());
}, 300, { leading: true });
render () {
const { intl, circle, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof circle === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (circle === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.circle_statuses' defaultMessage="You don't have any circle posts yet. When you post one as circle, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='user-circle'
title={circle.get('title')}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' /> <FormattedMessage id='circles.edit' defaultMessage='Edit circle' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='circles.delete' defaultMessage='Delete circle' />
</button>
</div>
</ColumnHeader>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`circle_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(CircleStatuses));

View file

@ -13,7 +13,6 @@ import { fetchCircles, deleteCircle } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link'; import ColumnLink from 'mastodon/features/ui/components/column_link';
@ -106,10 +105,7 @@ class Circles extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{circles.map(circle => {circles.map(circle =>
(<div key={circle.get('id')} className='circle-item'> <ColumnLink key={circle.get('id')} to={`/circles/${circle.get('id')}`} icon='user-circle' text={circle.get('title')} />,
<ColumnLink to={`#`} data-id={circle.get('id')} onClick={this.handleEditClick} icon='user-circle' text={circle.get('title')} />
<IconButton icon='trash' data_id={circle.get('id')} onClick={this.handleRemoveClick} />
</div>)
)} )}
</ScrollableList> </ScrollableList>

View file

@ -103,11 +103,11 @@ class ComposeForm extends ImmutablePureComponent {
}; };
canSubmit = () => { canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia, privacy, circleId } = this.props; const { isSubmitting, isChangingUpload, isUploading, anyMedia, privacy, circleId, isEditing } = this.props;
const fulltext = this.getFulltextForCharacterCounting(); const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !circleId)); return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia) || (privacy === 'circle' && !isEditing && !circleId));
}; };
handleSubmit = (e) => { handleSubmit = (e) => {

View file

@ -9,7 +9,7 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { enableLoginPrivacy } from 'mastodon/initial_state'; import { enableLoginPrivacy, enableLocalPrivacy } from 'mastodon/initial_state';
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
@ -246,6 +246,10 @@ class PrivacyDropdown extends PureComponent {
this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'login'); this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'login');
} }
if (!enableLocalPrivacy) {
this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'public_unlisted');
}
if (this.props.noDirect) { if (this.props.noDirect) {
this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'direct'); this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'direct');
} }

View file

@ -4,7 +4,7 @@ import { PureComponent } from 'react';
const iconStyle = { const iconStyle = {
height: null, height: null,
lineHeight: '27px', lineHeight: '27px',
width: `${18 * 1.28571429}px`, minWidth: `${18 * 1.28571429}px`,
}; };
export default class TextIconButton extends PureComponent { export default class TextIconButton extends PureComponent {

View file

@ -4,7 +4,7 @@ import { changeCircle } from '../../../actions/compose';
import CircleSelect from '../components/circle_select'; import CircleSelect from '../components/circle_select';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'privacy']) !== 'circle', unavailable: state.getIn(['compose', 'privacy']) !== 'circle' || !!state.getIn(['compose', 'id']),
circles: state.get('circles'), circles: state.get('circles'),
circleId: state.getIn(['compose', 'circle_id']), circleId: state.getIn(['compose', 'circle_id']),
}); });

View file

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags'; import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions';
import Warning from '../components/warning'; import Warning from '../components/warning';
@ -14,10 +15,11 @@ const mapStateToProps = state => ({
hashtagWarning: !['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])), hashtagWarning: !['public', 'public_unlisted', 'login'].includes(state.getIn(['compose', 'privacy'])) && state.getIn(['compose', 'searchability']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited', searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited',
limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])), mentionWarning: ['mutual', 'circle', 'limited'].includes(state.getIn(['compose', 'privacy'])) && MENTION_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
limitedPostWarning: ['mutual', 'circle'].includes(state.getIn(['compose', 'privacy'])) && !state.getIn(['compose', 'limited_scope']),
}); });
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, limitedPostWarning }) => { const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning, mentionWarning, limitedPostWarning }) => {
if (needsLockWarning) { if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
} }
@ -40,6 +42,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return <Warning message={<FormattedMessage id='compose_form.searchability_warning' defaultMessage='Self only searchability is not available other mastodon servers. Others can search your post.' />} />; return <Warning message={<FormattedMessage id='compose_form.searchability_warning' defaultMessage='Self only searchability is not available other mastodon servers. Others can search your post.' />} />;
} }
if (mentionWarning) {
return <Warning message={<FormattedMessage id='compose_form.mention_warning' defaultMessage='When you add a mention to a limited post, the person you are mentioning can also see this post.' />} />;
}
if (limitedPostWarning) { if (limitedPostWarning) {
return <Warning message={<FormattedMessage id='compose_form.limited_post_warning' defaultMessage='Limited posts are NOT reached Misskey, normal Mastodon or so on.' />} />; return <Warning message={<FormattedMessage id='compose_form.limited_post_warning' defaultMessage='Limited posts are NOT reached Misskey, normal Mastodon or so on.' />} />;
} }
@ -52,6 +58,7 @@ WarningWrapper.propTypes = {
hashtagWarning: PropTypes.bool, hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool, directMessageWarning: PropTypes.bool,
searchabilityWarning: PropTypes.bool, searchabilityWarning: PropTypes.bool,
mentionWarning: PropTypes.bool,
limitedPostWarning: PropTypes.bool, limitedPostWarning: PropTypes.bool,
}; };

View file

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { fetchMentionedUsers, expandMentionedUsers } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import AccountContainer from 'mastodon/containers/account_container';
import Column from 'mastodon/features/ui/components/column';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'isLoading'], true),
});
class MentionedUsers extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
UNSAFE_componentWillMount () {
if (!this.props.accountIds) {
this.props.dispatch(fetchMentionedUsers(this.props.params.statusId));
}
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandMentionedUsers(this.props.params.statusId));
}, 300, { leading: true });
render () {
const { accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.mentioned_users' defaultMessage='No one has been mentioned by this post.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='mentioned_users'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(MentionedUsers));

View file

@ -23,6 +23,7 @@ const messages = defineMessages({
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
}); });
@ -57,6 +58,7 @@ class StatusCheckBox extends PureComponent {
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
}; };

View file

@ -22,6 +22,7 @@ const messages = defineMessages({
edit: { id: 'status.edit', defaultMessage: 'Edit' }, edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -95,6 +96,10 @@ class ActionBar extends PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
handleOpenMentions = () => {
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`);
};
handleReplyClick = () => { handleReplyClick = () => {
this.props.onReply(this.props.status); this.props.onReply(this.props.status);
}; };
@ -264,6 +269,7 @@ class ActionBar extends PureComponent {
menu.push(null); menu.push(null);
} }
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });

View file

@ -35,6 +35,7 @@ const messages = defineMessages({
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' }, searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
@ -260,6 +261,7 @@ class DetailedStatus extends ImmutablePureComponent {
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
}; };

View file

@ -30,6 +30,7 @@ const messages = defineMessages({
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' }, circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
}); });
@ -100,6 +101,7 @@ class BoostModal extends ImmutablePureComponent {
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) }, 'circle': { icon: 'user-circle', text: intl.formatMessage(messages.circle_short) },
'personal': { icon: 'sticky-note-o', text: intl.formatMessage(messages.personal_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
}; };

View file

@ -24,6 +24,7 @@ import {
BookmarkCategoryStatuses, BookmarkCategoryStatuses,
AntennaSetting, AntennaSetting,
AntennaTimeline, AntennaTimeline,
CircleStatuses,
} from '../util/async-components'; } from '../util/async-components';
import BundleColumnError from './bundle_column_error'; import BundleColumnError from './bundle_column_error';
@ -45,6 +46,7 @@ const componentMap = {
'EMOJI_REACTIONS': EmojiReactedStatuses, 'EMOJI_REACTIONS': EmojiReactedStatuses,
'BOOKMARKS': BookmarkedStatuses, 'BOOKMARKS': BookmarkedStatuses,
'BOOKMARKS_EX': BookmarkCategoryStatuses, 'BOOKMARKS_EX': BookmarkCategoryStatuses,
'CIRCLE_STATUSES': CircleStatuses,
'ANTENNA': AntennaSetting, 'ANTENNA': AntennaSetting,
'ANTENNA_TIMELINE': AntennaTimeline, 'ANTENNA_TIMELINE': AntennaTimeline,
'LIST': ListTimeline, 'LIST': ListTimeline,

View file

@ -46,6 +46,7 @@ import {
Favourites, Favourites,
EmojiReactions, EmojiReactions,
StatusReferences, StatusReferences,
MentionedUsers,
DirectTimeline, DirectTimeline,
HashtagTimeline, HashtagTimeline,
AntennaTimeline, AntennaTimeline,
@ -65,6 +66,7 @@ import {
Lists, Lists,
Antennas, Antennas,
Circles, Circles,
CircleStatuses,
AntennaSetting, AntennaSetting,
Directory, Directory,
Explore, Explore,
@ -90,7 +92,7 @@ const mapStateToProps = state => ({
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, dropdownMenuIsOpen: state.dropdownMenu.openId !== null,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
username: state.getIn(['accounts', me, 'username']), username: state.getIn(['accounts', me, 'username']),
}); });
@ -242,6 +244,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} /> <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/@:acct/:statusId/emoji_reactions' component={EmojiReactions} content={children} /> <WrappedRoute path='/@:acct/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path='/@:acct/:statusId/references' component={StatusReferences} content={children} /> <WrappedRoute path='/@:acct/:statusId/references' component={StatusReferences} content={children} />
<WrappedRoute path='/@:acct/:statusId/mentioned_users' component={MentionedUsers} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */} {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
@ -259,6 +262,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/antennasw' component={Antennas} content={children} /> <WrappedRoute path='/antennasw' component={Antennas} content={children} />
<WrappedRoute path='/circles/:id' component={CircleStatuses} content={children} />
<WrappedRoute path='/circles' component={Circles} content={children} /> <WrappedRoute path='/circles' component={Circles} content={children} />
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />

View file

@ -54,6 +54,10 @@ export function Circles () {
return import(/* webpackChunkName: "features/circles" */'../../circles'); return import(/* webpackChunkName: "features/circles" */'../../circles');
} }
export function CircleStatuses () {
return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses');
}
export function Status () { export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status'); return import(/* webpackChunkName: "features/status" */'../../status');
} }
@ -102,6 +106,10 @@ export function StatusReferences () {
return import(/* webpackChunkName: "features/status_references" */'../../status_references'); return import(/* webpackChunkName: "features/status_references" */'../../status_references');
} }
export function MentionedUsers () {
return import(/* webpackChunkName: "features/mentioned_users" */'../../mentioned_users');
}
export function FollowRequests () { export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
} }

View file

@ -61,6 +61,7 @@
* @property {string} dtl_tag * @property {string} dtl_tag
* @property {boolean} enable_emoji_reaction * @property {boolean} enable_emoji_reaction
* @property {boolean} enable_login_privacy * @property {boolean} enable_login_privacy
* @property {boolean} enable_local_privacy
* @property {boolean} enable_dtl_menu * @property {boolean} enable_dtl_menu
* @property {boolean=} expand_spoilers * @property {boolean=} expand_spoilers
* @property {boolean} hide_recent_emojis * @property {boolean} hide_recent_emojis
@ -130,6 +131,7 @@ export const displayMediaExpand = getMeta('display_media_expand');
export const domain = getMeta('domain'); export const domain = getMeta('domain');
export const dtlTag = getMeta('dtl_tag'); export const dtlTag = getMeta('dtl_tag');
export const enableEmojiReaction = getMeta('enable_emoji_reaction'); export const enableEmojiReaction = getMeta('enable_emoji_reaction');
export const enableLocalPrivacy = getMeta('enable_local_privacy');
export const enableLoginPrivacy = getMeta('enable_login_privacy'); export const enableLoginPrivacy = getMeta('enable_login_privacy');
export const enableDtlMenu = getMeta('enable_dtl_menu'); export const enableDtlMenu = getMeta('enable_dtl_menu');
export const expandSpoilers = getMeta('expand_spoilers'); export const expandSpoilers = getMeta('expand_spoilers');

View file

@ -1,6 +1,7 @@
{ {
"about.blocks": "Moderated servers", "about.blocks": "Moderated servers",
"about.contact": "Contact:", "about.contact": "Contact:",
"about.disabled": "Disabled",
"about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.", "about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Reason not available", "about.domain_blocks.no_reason_available": "Reason not available",
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.", "about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.",
@ -10,6 +11,8 @@
"about.domain_blocks.silenced.title": "Limited", "about.domain_blocks.silenced.title": "Limited",
"about.domain_blocks.suspended.explanation": "No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.", "about.domain_blocks.suspended.explanation": "No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.",
"about.domain_blocks.suspended.title": "Suspended", "about.domain_blocks.suspended.title": "Suspended",
"about.enabled": "Enabled",
"about.kmyblue_capability": "This server is using kmyblue, a fork of Mastodon. On this server, kmyblues unique features are configured as follows.",
"about.not_available": "This information has not been made available on this server.", "about.not_available": "This information has not been made available on this server.",
"about.powered_by": "Decentralized social media powered by {mastodon}", "about.powered_by": "Decentralized social media powered by {mastodon}",
"about.rules": "Server rules", "about.rules": "Server rules",
@ -104,6 +107,8 @@
"bundle_modal_error.close": "Close", "bundle_modal_error.close": "Close",
"bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.message": "Something went wrong while loading this component.",
"bundle_modal_error.retry": "Try again", "bundle_modal_error.retry": "Try again",
"circles.delete": "Delete circle",
"circles.edit": "Edit circle",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.", "closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
"closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.", "closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.",
"closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.find_another_server": "Find another server",
@ -151,6 +156,7 @@
"compose_form.lock_disclaimer.lock": "locked", "compose_form.lock_disclaimer.lock": "locked",
"compose_form.markdown.marked": "Markdown is available", "compose_form.markdown.marked": "Markdown is available",
"compose_form.markdown.unmarked": "Markdown is NOT available", "compose_form.markdown.unmarked": "Markdown is NOT available",
"compose_form.mention_warning": "When you add a mention to a limited post, the person you are mentioning can also see this post.",
"compose_form.placeholder": "What's on your mind?", "compose_form.placeholder": "What's on your mind?",
"compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.", "compose_form.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.",
"compose_form.poll.add_option": "Add a choice", "compose_form.poll.add_option": "Add a choice",
@ -236,6 +242,7 @@
"empty_column.account_unavailable": "Profile unavailable", "empty_column.account_unavailable": "Profile unavailable",
"empty_column.blocks": "You haven't blocked any users yet.", "empty_column.blocks": "You haven't blocked any users yet.",
"empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.", "empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.",
"empty_column.circle_statuses": "You don't have any circle posts yet. When you post one as circle, it will show up here.",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any private mentions yet. When you send or receive one, it will show up here.", "empty_column.direct": "You don't have any private mentions yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no blocked domains yet.", "empty_column.domain_blocks": "There are no blocked domains yet.",
@ -533,6 +540,7 @@
"privacy.login.short": "Login only", "privacy.login.short": "Login only",
"privacy.mutual.long": "Mutual followers only", "privacy.mutual.long": "Mutual followers only",
"privacy.mutual.short": "Mutual", "privacy.mutual.short": "Mutual",
"privacy.personal.short": "Yourself only",
"privacy.private.long": "Visible for followers only", "privacy.private.long": "Visible for followers only",
"privacy.private.short": "Followers only", "privacy.private.short": "Followers only",
"privacy.public.long": "Visible for all", "privacy.public.long": "Visible for all",

View file

@ -1,6 +1,7 @@
{ {
"about.blocks": "制限中のサーバー", "about.blocks": "制限中のサーバー",
"about.contact": "連絡先", "about.contact": "連絡先",
"about.disabled": "無効",
"about.disclaimer": "Mastodonは自由なオープンソースソフトウェアであり、Mastodon gGmbHの商標です。", "about.disclaimer": "Mastodonは自由なオープンソースソフトウェアであり、Mastodon gGmbHの商標です。",
"about.domain_blocks.no_reason_available": "理由未記載", "about.domain_blocks.no_reason_available": "理由未記載",
"about.domain_blocks.preamble": "Mastodonでは原則的にあらゆるサーバー同士で交流したり、互いの投稿を読んだりできますが、当サーバーでは例外的に次のような制限を設けています。", "about.domain_blocks.preamble": "Mastodonでは原則的にあらゆるサーバー同士で交流したり、互いの投稿を読んだりできますが、当サーバーでは例外的に次のような制限を設けています。",
@ -10,6 +11,8 @@
"about.domain_blocks.silenced.title": "制限", "about.domain_blocks.silenced.title": "制限",
"about.domain_blocks.suspended.explanation": "これらのサーバーからのデータは処理されず、保存や変換もされません。該当するユーザーとの交流もできません。", "about.domain_blocks.suspended.explanation": "これらのサーバーからのデータは処理されず、保存や変換もされません。該当するユーザーとの交流もできません。",
"about.domain_blocks.suspended.title": "停止中", "about.domain_blocks.suspended.title": "停止中",
"about.enabled": "有効",
"about.kmyblue_capability": "このサーバーは、kmyblueというMastodonフォークを利用しています。kmyblue独自機能の一部は、サーバー管理者によって有効・無効を切り替えることができます。",
"about.not_available": "この情報はこのサーバーでは利用できません。", "about.not_available": "この情報はこのサーバーでは利用できません。",
"about.powered_by": "{mastodon}による分散型ソーシャルメディア", "about.powered_by": "{mastodon}による分散型ソーシャルメディア",
"about.rules": "サーバーのルール", "about.rules": "サーバーのルール",
@ -154,8 +157,10 @@
"bundle_modal_error.close": "閉じる", "bundle_modal_error.close": "閉じる",
"bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。", "bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。",
"bundle_modal_error.retry": "再試行", "bundle_modal_error.retry": "再試行",
"circles.account.add": "おはぎに追加", "circles.account.add": "サークルに追加",
"circles.account.remove": "おはぎから外す", "circles.account.remove": "サークルから外す",
"circles.delete": "サークルを削除",
"circles.edit": "サークルを編集",
"circles.edit.submit": "タイトルを変更", "circles.edit.submit": "タイトルを変更",
"circles.new.create": "サークルを作成", "circles.new.create": "サークルを作成",
"circles.new.title_placeholder": "新規サークル名", "circles.new.title_placeholder": "新規サークル名",
@ -213,6 +218,7 @@
"compose_form.lock_disclaimer.lock": "承認制", "compose_form.lock_disclaimer.lock": "承認制",
"compose_form.markdown.marked": "Markdown有効", "compose_form.markdown.marked": "Markdown有効",
"compose_form.markdown.unmarked": "Markdownは有効になっていません", "compose_form.markdown.unmarked": "Markdownは有効になっていません",
"compose_form.mention_warning": "限定投稿にメンションを追加すると、そのアカウントはサークルメンバー・相互などに関係なくこの投稿を読むことができます",
"compose_form.placeholder": "今なにしてる?", "compose_form.placeholder": "今なにしてる?",
"compose_form.searchability_warning": "検索許可「自分のみ」はkmyblue内の検索でのみ有効です。他のサーバーでは「リアクションした人のみ」と同等に扱われます", "compose_form.searchability_warning": "検索許可「自分のみ」はkmyblue内の検索でのみ有効です。他のサーバーでは「リアクションした人のみ」と同等に扱われます",
"compose_form.poll.add_option": "追加", "compose_form.poll.add_option": "追加",
@ -308,6 +314,7 @@
"empty_column.bookmark_categories": "まだ分類がありません。分類を作るとここに表示されます。", "empty_column.bookmark_categories": "まだ分類がありません。分類を作るとここに表示されます。",
"empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。", "empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。",
"empty_column.circles": "まだサークルがありません。サークルを作るとここに表示されます。", "empty_column.circles": "まだサークルがありません。サークルを作るとここに表示されます。",
"empty_column.circle_statuses": "まだサークル投稿がありません。このサークルでなにか投稿するとここに表示されます。",
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
"empty_column.direct": "非公開の返信はまだありません。非公開でやりとりをするとここに表示されます。", "empty_column.direct": "非公開の返信はまだありません。非公開でやりとりをするとここに表示されます。",
"empty_column.domain_blocks": "ブロックしているドメインはありません。", "empty_column.domain_blocks": "ブロックしているドメインはありません。",
@ -618,6 +625,7 @@
"privacy.login.short": "ログインユーザーのみ", "privacy.login.short": "ログインユーザーのみ",
"privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿", "privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿",
"privacy.mutual.short": "相互のみ", "privacy.mutual.short": "相互のみ",
"privacy.personal.short": "自分限定",
"privacy.private.long": "フォロワーのみ閲覧可", "privacy.private.long": "フォロワーのみ閲覧可",
"privacy.private.short": "フォロワーのみ", "privacy.private.short": "フォロワーのみ",
"privacy.public.long": "誰でも閲覧可、ホームローカル連合TL", "privacy.public.long": "誰でも閲覧可、ホームローカル連合TL",

View file

@ -48,11 +48,11 @@
"account.media": "Médiá", "account.media": "Médiá",
"account.mention": "Spomeň @{name}", "account.mention": "Spomeň @{name}",
"account.moved_to": "{name} uvádza, že jeho/jej nový účet je teraz:", "account.moved_to": "{name} uvádza, že jeho/jej nový účet je teraz:",
"account.mute": "Stíš @{name}", "account.mute": "Nevšímaj si @{name}",
"account.mute_notifications_short": "Stíš oznámenia", "account.mute_notifications_short": "Stíš oboznámenia",
"account.mute_short": "Stíš", "account.mute_short": "Nevšímaj si",
"account.muted": "Stíšený", "account.muted": "Nevšímaný/á",
"account.no_bio": "Nie je uvedený žiadny popis.", "account.no_bio": "Nieje uvedený žiadny popis.",
"account.open_original_page": "Otvor pôvodnú stránku", "account.open_original_page": "Otvor pôvodnú stránku",
"account.posts": "Príspevky", "account.posts": "Príspevky",
"account.posts_with_replies": "Príspevky a odpovede", "account.posts_with_replies": "Príspevky a odpovede",
@ -307,9 +307,8 @@
"home.column_settings.basic": "Základné", "home.column_settings.basic": "Základné",
"home.column_settings.show_reblogs": "Ukáž vyzdvihnuté", "home.column_settings.show_reblogs": "Ukáž vyzdvihnuté",
"home.column_settings.show_replies": "Ukáž odpovede", "home.column_settings.show_replies": "Ukáž odpovede",
"home.explore_prompt.body": "Váš domovský informačný kanál bude obsahovať mix príspevkov z mriežok, ktoré ste sa rozhodli sledovať, ľudí, ktorých ste sa rozhodli sledovať, a príspevkov, ktoré preferujú. Ak sa vám to zdá príliš málo, možno budete chcieť:",
"home.explore_prompt.title": "Toto je tvoja domovina v rámci Mastodonu.", "home.explore_prompt.title": "Toto je tvoja domovina v rámci Mastodonu.",
"home.hide_announcements": "Skry oznámenia", "home.hide_announcements": "Skry oboznámenia",
"home.pending_critical_update.body": "Prosím aktualizuj si svoj Mastodon server, ako náhle to bude možné!", "home.pending_critical_update.body": "Prosím aktualizuj si svoj Mastodon server, ako náhle to bude možné!",
"home.pending_critical_update.link": "Pozri aktualizácie", "home.pending_critical_update.link": "Pozri aktualizácie",
"home.pending_critical_update.title": "Je dostupná kritická bezpečnostná aktualizácia!", "home.pending_critical_update.title": "Je dostupná kritická bezpečnostná aktualizácia!",

View file

@ -1,4 +1,4 @@
import { List as ImmutableList, fromJS } from 'immutable'; import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { import {
CIRCLE_FETCH_SUCCESS, CIRCLE_FETCH_SUCCESS,
@ -7,31 +7,107 @@ import {
CIRCLE_CREATE_SUCCESS, CIRCLE_CREATE_SUCCESS,
CIRCLE_UPDATE_SUCCESS, CIRCLE_UPDATE_SUCCESS,
CIRCLE_DELETE_SUCCESS, CIRCLE_DELETE_SUCCESS,
CIRCLE_STATUSES_FETCH_REQUEST,
CIRCLE_STATUSES_FETCH_SUCCESS,
CIRCLE_STATUSES_FETCH_FAIL,
CIRCLE_STATUSES_EXPAND_REQUEST,
CIRCLE_STATUSES_EXPAND_SUCCESS,
CIRCLE_STATUSES_EXPAND_FAIL,
} from '../actions/circles'; } from '../actions/circles';
import {
COMPOSE_WITH_CIRCLE_SUCCESS,
} from '../actions/compose';
const initialState = ImmutableList(); const initialState = ImmutableList();
const normalizeList = (state, circle) => state.set(circle.id, fromJS(circle)); const initialStatusesState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
loaded: true,
next: null,
});
const normalizeLists = (state, circles) => { const normalizeCircle = (state, circle) => {
const old = state.get(circle.id);
if (old === false) {
return state;
}
let s = state.set(circle.id, fromJS(circle));
if (old) {
s = s.setIn([circle.id, 'statuses'], old.get('statuses'));
} else {
s = s.setIn([circle.id, 'statuses'], initialStatusesState);
}
return s.setIn([circle.id, 'isLoading'], false).setIn([circle.id, 'isLoaded'], true);
};
const normalizeCircles = (state, circles) => {
circles.forEach(circle => { circles.forEach(circle => {
state = normalizeList(state, circle); state = normalizeCircle(state, circle);
}); });
return state; return state;
}; };
const normalizeCircleStatuses = (state, circleId, statuses, next) => {
return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
}));
};
const appendToCircleStatuses = (state, circleId, statuses, next) => {
return appendToCircleStatusesById(state, circleId, statuses.map(item => item.id), next);
};
const appendToCircleStatusesById = (state, circleId, statuses, next) => {
return state.updateIn([circleId, 'statuses'], listMap => listMap.withMutations(map => {
if (typeof next !== 'undefined') {
map.set('next', next);
}
map.set('isLoading', false);
if (map.get('items')) {
map.set('items', map.get('items').union(statuses));
}
}));
};
const prependToCircleStatusById = (state, circleId, statusId) => {
if (!state.get(circleId)) return state;
return state.updateIn([circleId], circle => circle.withMutations(map => {
if (map.getIn(['statuses', 'items'])) {
map.updateIn(['statuses', 'items'], list => ImmutableOrderedSet([statusId]).union(list));
}
}));
}
export default function circles(state = initialState, action) { export default function circles(state = initialState, action) {
switch(action.type) { switch(action.type) {
case CIRCLE_FETCH_SUCCESS: case CIRCLE_FETCH_SUCCESS:
case CIRCLE_CREATE_SUCCESS: case CIRCLE_CREATE_SUCCESS:
case CIRCLE_UPDATE_SUCCESS: case CIRCLE_UPDATE_SUCCESS:
return normalizeList(state, action.circle); return normalizeCircle(state, action.circle);
case CIRCLES_FETCH_SUCCESS: case CIRCLES_FETCH_SUCCESS:
return normalizeLists(state, action.circles); return normalizeCircles(state, action.circles);
case CIRCLE_DELETE_SUCCESS: case CIRCLE_DELETE_SUCCESS:
case CIRCLE_FETCH_FAIL: case CIRCLE_FETCH_FAIL:
return state.set(action.id, false); return state.set(action.id, false);
case CIRCLE_STATUSES_FETCH_REQUEST:
case CIRCLE_STATUSES_EXPAND_REQUEST:
return state.setIn([action.id, 'statuses', 'isLoading'], true);
case CIRCLE_STATUSES_FETCH_FAIL:
case CIRCLE_STATUSES_EXPAND_FAIL:
return state.setIn([action.id, 'statuses', 'isLoading'], false);
case CIRCLE_STATUSES_FETCH_SUCCESS:
return normalizeCircleStatuses(state, action.id, action.statuses, action.next);
case CIRCLE_STATUSES_EXPAND_SUCCESS:
return appendToCircleStatuses(state, action.id, action.statuses, action.next);
case COMPOSE_WITH_CIRCLE_SUCCESS:
return prependToCircleStatusById(state, action.circleId, action.status.id);
default: default:
return state; return state;
} }

View file

@ -58,7 +58,7 @@ import {
import { REDRAFT } from '../actions/statuses'; import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { me } from '../initial_state'; import { enableLocalPrivacy, enableLoginPrivacy, me } from '../initial_state';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { uuid } from '../uuid'; import { uuid } from '../uuid';
@ -138,9 +138,13 @@ function clearAll(state) {
if (state.get('stay_privacy') && !state.get('in_reply_to')) { if (state.get('stay_privacy') && !state.get('in_reply_to')) {
map.set('default_privacy', state.get('privacy')); map.set('default_privacy', state.get('privacy'));
} }
if ((map.get('privacy') === 'login' && !enableLoginPrivacy) || (map.get('privacy') === 'public_unlisted' && !enableLocalPrivacy)) {
map.set('privacy', 'public');
}
if (!state.get('in_reply_to')) { if (!state.get('in_reply_to')) {
map.set('posted_on_this_session', true); map.set('posted_on_this_session', true);
} }
map.set('limited_scope', null);
map.set('id', null); map.set('id', null);
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('searchability', state.get('default_searchability')); map.set('searchability', state.get('default_searchability'));
@ -408,6 +412,7 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status)); map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility_ex'), state.get('default_privacy'))); map.set('privacy', privacyPreference(action.status.get('visibility_ex'), state.get('default_privacy')));
map.set('limited_scope', null);
map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability'))); map.set('searchability', privacyPreference(action.status.get('searchability'), state.get('default_searchability')));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
@ -544,6 +549,7 @@ export default function compose(state = initialState, action) {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility_ex')); map.set('privacy', action.status.get('visibility_ex'));
map.set('limited_scope', null);
map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true))); map.set('media_attachments', action.status.get('media_attachments').map((media) => media.set('unattached', true)));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
@ -574,7 +580,12 @@ export default function compose(state = initialState, action) {
map.set('id', action.status.get('id')); map.set('id', action.status.get('id'));
map.set('text', action.text); map.set('text', action.text);
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('privacy', action.status.get('visibility_ex')); if (action.status.get('visibility_ex') !== 'limited') {
map.set('privacy', action.status.get('visibility_ex'));
} else {
map.set('privacy', action.status.get('limited_scope') === 'mutual' ? 'mutual' : 'circle');
}
map.set('limited_scope', action.status.get('limited_scope'));
map.set('media_attachments', action.status.get('media_attachments')); map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);

View file

@ -1,19 +0,0 @@
import Immutable from 'immutable';
import {
DROPDOWN_MENU_OPEN,
DROPDOWN_MENU_CLOSE,
} from '../actions/dropdown_menu';
const initialState = Immutable.Map({ openId: null, keyboard: false, scroll_key: null });
export default function dropdownMenu(state = initialState, action) {
switch (action.type) {
case DROPDOWN_MENU_OPEN:
return state.merge({ openId: action.id, keyboard: action.keyboard, scroll_key: action.scroll_key });
case DROPDOWN_MENU_CLOSE:
return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state;
default:
return state;
}
}

View file

@ -0,0 +1,33 @@
import { createReducer } from '@reduxjs/toolkit';
import { closeDropdownMenu, openDropdownMenu } from '../actions/dropdown_menu';
interface DropdownMenuState {
openId: string | null;
keyboard: boolean;
scrollKey: string | null;
}
const initialState: DropdownMenuState = {
openId: null,
keyboard: false,
scrollKey: null,
};
export const dropdownMenuReducer = createReducer(initialState, (builder) => {
builder
.addCase(
openDropdownMenu,
(state, { payload: { id, keyboard, scrollKey } }) => {
state.openId = id;
state.keyboard = keyboard;
state.scrollKey = scrollKey;
},
)
.addCase(closeDropdownMenu, (state, { payload: { id } }) => {
if (state.openId === id) {
state.openId = null;
state.scrollKey = null;
}
});
});

View file

@ -24,7 +24,7 @@ import contexts from './contexts';
import conversations from './conversations'; import conversations from './conversations';
import custom_emojis from './custom_emojis'; import custom_emojis from './custom_emojis';
import domain_lists from './domain_lists'; import domain_lists from './domain_lists';
import dropdown_menu from './dropdown_menu'; import { dropdownMenuReducer } from './dropdown_menu';
import filters from './filters'; import filters from './filters';
import followed_tags from './followed_tags'; import followed_tags from './followed_tags';
import height_cache from './height_cache'; import height_cache from './height_cache';
@ -56,7 +56,7 @@ import user_lists from './user_lists';
const reducers = { const reducers = {
announcements, announcements,
dropdown_menu, dropdownMenu: dropdownMenuReducer,
timelines, timelines,
meta, meta,
alerts, alerts,

View file

@ -1,13 +1,13 @@
import { Record as ImmutableRecord, Stack } from 'immutable'; import { Record as ImmutableRecord, Stack } from 'immutable';
import type { PayloadAction } from '@reduxjs/toolkit'; import type { Reducer } from '@reduxjs/toolkit';
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose'; import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
import type { ModalType } from '../actions/modal'; import type { ModalType } from '../actions/modal';
import { openModal, closeModal } from '../actions/modal'; import { openModal, closeModal } from '../actions/modal';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
type ModalProps = Record<string, unknown>; export type ModalProps = Record<string, unknown>;
interface Modal { interface Modal {
modalType: ModalType; modalType: ModalType;
modalProps: ModalProps; modalProps: ModalProps;
@ -62,33 +62,22 @@ const pushModal = (
}); });
}; };
export function modalReducer( export const modalReducer: Reducer<State> = (state = initialState, action) => {
state: State = initialState, if (openModal.match(action))
action: PayloadAction<{ return pushModal(
modalType: ModalType; state,
ignoreFocus: boolean; action.payload.modalType,
modalProps: Record<string, unknown>; action.payload.modalProps,
}>, );
) { else if (closeModal.match(action)) return popModal(state, action.payload);
switch (action.type) { // TODO: type those actions
case openModal.type: else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS)
return pushModal( return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
state, else if (action.type === TIMELINE_DELETE)
action.payload.modalType, return state.update('stack', (stack) =>
action.payload.modalProps, stack.filterNot(
); (modal) => modal.get('modalProps').statusId === action.id,
case closeModal.type: ),
return popModal(state, action.payload); );
case COMPOSE_UPLOAD_CHANGE_SUCCESS: else return state;
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); };
case TIMELINE_DELETE:
return state.update('stack', (stack) =>
stack.filterNot(
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
(modal) => modal.get('modalProps').statusId === action.id,
),
);
default:
return state;
}
}

View file

@ -64,6 +64,12 @@ import {
EMOJI_REACTIONS_EXPAND_SUCCESS, EMOJI_REACTIONS_EXPAND_SUCCESS,
EMOJI_REACTIONS_EXPAND_FAIL, EMOJI_REACTIONS_EXPAND_FAIL,
STATUS_REFERENCES_FETCH_SUCCESS, STATUS_REFERENCES_FETCH_SUCCESS,
MENTIONED_USERS_FETCH_REQUEST,
MENTIONED_USERS_FETCH_SUCCESS,
MENTIONED_USERS_FETCH_FAIL,
MENTIONED_USERS_EXPAND_REQUEST,
MENTIONED_USERS_EXPAND_SUCCESS,
MENTIONED_USERS_EXPAND_FAIL,
} from '../actions/interactions'; } from '../actions/interactions';
import { import {
MUTES_FETCH_REQUEST, MUTES_FETCH_REQUEST,
@ -92,6 +98,7 @@ const initialState = ImmutableMap({
favourited_by: initialListState, favourited_by: initialListState,
emoji_reactioned_by: initialListState, emoji_reactioned_by: initialListState,
referred_by: initialListState, referred_by: initialListState,
mentioned_users: initialListState,
follow_requests: initialListState, follow_requests: initialListState,
blocks: initialListState, blocks: initialListState,
mutes: initialListState, mutes: initialListState,
@ -205,6 +212,16 @@ export default function userLists(state = initialState, action) {
return appendToEmojiReactionList(state, ['emoji_reactioned_by', action.id], action.accounts, action.next); return appendToEmojiReactionList(state, ['emoji_reactioned_by', action.id], action.accounts, action.next);
case STATUS_REFERENCES_FETCH_SUCCESS: case STATUS_REFERENCES_FETCH_SUCCESS:
return state.setIn(['referred_by', action.id], ImmutableList(action.statuses.map(item => item.id))); return state.setIn(['referred_by', action.id], ImmutableList(action.statuses.map(item => item.id)));
case MENTIONED_USERS_FETCH_SUCCESS:
return normalizeList(state, ['mentioned_users', action.id], action.accounts, action.next);
case MENTIONED_USERS_EXPAND_SUCCESS:
return appendToList(state, ['mentioned_users', action.id], action.accounts, action.next);
case MENTIONED_USERS_FETCH_REQUEST:
case MENTIONED_USERS_EXPAND_REQUEST:
return state.setIn(['mentioned_users', action.id, 'isLoading'], true);
case MENTIONED_USERS_FETCH_FAIL:
case MENTIONED_USERS_EXPAND_FAIL:
return state.setIn(['mentioned_users', action.id, 'isLoading'], false);
case NOTIFICATIONS_UPDATE: case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS: case FOLLOW_REQUESTS_FETCH_SUCCESS:

View file

@ -135,3 +135,7 @@ export const getStatusList = createSelector([
export const getBookmarkCategoryStatusList = createSelector([ export const getBookmarkCategoryStatusList = createSelector([
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']), (state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
], (items) => items ? items.toList() : ImmutableList()); ], (items) => items ? items.toList() : ImmutableList());
export const getCircleStatusList = createSelector([
(state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']),
], (items) => items ? items.toList() : ImmutableList());

View file

@ -0,0 +1,29 @@
const MENTION_SEPARATORS = '_\\u00b7\\u200c';
const ALPHA = '\\p{L}\\p{M}';
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
const buildMentionPatternRegex = () => {
try {
return new RegExp(
`(?:^|[^\\/\\)\\w])@(([${WORD}_][${WORD}${MENTION_SEPARATORS}]*[${ALPHA}${MENTION_SEPARATORS}][${WORD}${MENTION_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))`,
'iu',
);
} catch {
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
}
};
const buildMentionRegex = () => {
try {
return new RegExp(
`^(([${WORD}_][${WORD}${MENTION_SEPARATORS}]*[${ALPHA}${MENTION_SEPARATORS}][${WORD}${MENTION_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`,
'iu',
);
} catch {
return /^(\w*[a-zA-Z·]\w*)$/i;
}
};
export const MENTION_PATTERN_REGEX = buildMentionPatternRegex();
export const MENTION_REGEX = buildMentionRegex();

View file

@ -284,6 +284,7 @@
font-size: 11px; font-size: 11px;
padding: 0 3px; padding: 0 3px;
line-height: 27px; line-height: 27px;
white-space: nowrap;
&:hover, &:hover,
&:active, &:active,

View file

@ -72,12 +72,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
end end
def audience_searchable_by
return nil if @object['searchableBy'].nil?
@audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) }
end
def process_status def process_status
@tags = [] @tags = []
@mentions = [] @mentions = []
@ -120,7 +114,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_status_params def process_status_params
@status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object) @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object, account: @account)
@params = { @params = {
uri: @status_parser.uri, uri: @status_parser.uri,
@ -136,7 +130,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
sensitive: @account.sensitized? || @status_parser.sensitive || false, sensitive: @account.sensitized? || @status_parser.sensitive || false,
visibility: @status_parser.visibility, visibility: @status_parser.visibility,
limited_scope: @status_parser.limited_scope, limited_scope: @status_parser.limited_scope,
searchability: searchability, searchability: @status_parser.searchability,
thread: replied_to_status, thread: replied_to_status,
conversation: conversation_from_uri(@object['conversation']), conversation: conversation_from_uri(@object['conversation']),
media_attachment_ids: process_attachments.take(MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX).map(&:id), media_attachment_ids: process_attachments.take(MediaAttachment::ACTIVITYPUB_STATUS_ATTACHMENT_MAX).map(&:id),
@ -500,94 +494,4 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def join_group! def join_group!
GroupReblogService.new.call(@status) GroupReblogService.new.call(@status)
end end
def searchability_from_audience
if audience_searchable_by.nil?
nil
elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
:public
elsif audience_searchable_by.include?('as:Limited')
:limited
elsif audience_searchable_by.include?(@account.followers_url)
:private
else
:direct
end
end
SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/
SCAN_SEARCHABILITY_FEDIBIRD_RE = /searchable_by_(all_users|followers_only|reacted_users_only|nobody)/
def searchability
from_audience = searchability_from_audience
return from_audience if from_audience
return nil if default_searchability_from_bio?
searchability_from_bio || (misskey_software? ? misskey_searchability : nil)
end
def default_searchability_from_bio?
note = @account.note
return false if note.blank?
note.include?('searchable_by_default_range')
end
def searchability_from_bio
note = @account.note
return nil if note.blank?
searchability_bio = note.scan(SCAN_SEARCHABILITY_FEDIBIRD_RE).first || note.scan(SCAN_SEARCHABILITY_RE).first
return nil unless searchability_bio
searchability = searchability_bio[0]
return nil if searchability.nil?
searchability = :public if %w(public all_users).include?(searchability)
searchability = :private if %w(followers followers_only).include?(searchability)
searchability = :direct if %w(reactors reacted_users_only).include?(searchability)
searchability = :limited if %w(private nobody).include?(searchability)
searchability
end
def instance_info
@instance_info ||= InstanceInfo.find_by(domain: @account.domain)
end
def misskey_software?
info = instance_info
return false if info.nil?
%w(misskey calckey).include?(info.software)
end
def misskey_searchability
visibility = visibility_from_audience
%i(public unlisted).include?(visibility) ? :public : :limited
end
def visibility_from_audience
if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
:public
elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
:unlisted
elsif audience_to.include?('as:LoginOnly') || audience_to.include?('LoginUser')
:login
elsif audience_to.include?(@account.followers_url)
:private
else
:direct
end
end
def visibility_from_audience_with_silence
visibility = visibility_from_audience
if @account.silenced? && %i(public).include?(visibility)
:unlisted
else
visibility
end
end
end end

View file

@ -10,6 +10,7 @@ class ActivityPub::Parser::StatusParser
@json = json @json = json
@object = magic_values[:object] || json['object'] || json @object = magic_values[:object] || json['object'] || json
@magic_values = magic_values @magic_values = magic_values
@account = magic_values[:account]
end end
def uri def uri
@ -86,6 +87,14 @@ class ActivityPub::Parser::StatusParser
end end
end end
def searchability
from_audience = searchability_from_audience
return from_audience if from_audience
return nil if default_searchability_from_bio?
searchability_from_bio || (misskey_software? ? misskey_searchability : nil)
end
def limited_scope def limited_scope
case @object['limitedScope'] case @object['limitedScope']
when 'Mutual' when 'Mutual'
@ -98,6 +107,10 @@ class ActivityPub::Parser::StatusParser
end end
def language def language
@language ||= original_language || (misskey_software? ? 'ja' : nil)
end
def original_language
if content_language_map? if content_language_map?
@object['contentMap'].keys.first @object['contentMap'].keys.first
elsif name_language_map? elsif name_language_map?
@ -117,6 +130,12 @@ class ActivityPub::Parser::StatusParser
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
end end
def audience_searchable_by
return nil if @object['searchableBy'].nil?
@audience_searchable_by = as_array(@object['searchableBy']).map { |x| value_or_id(x) }
end
def summary_language_map? def summary_language_map?
@object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
end end
@ -128,4 +147,61 @@ class ActivityPub::Parser::StatusParser
def name_language_map? def name_language_map?
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
end end
def instance_info
@instance_info ||= InstanceInfo.find_by(domain: @account.domain)
end
def misskey_software?
info = instance_info
return false if info.nil?
%w(misskey calckey).include?(info.software)
end
def misskey_searchability
%i(public unlisted).include?(visibility) ? :public : :limited
end
SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/
SCAN_SEARCHABILITY_FEDIBIRD_RE = /searchable_by_(all_users|followers_only|reacted_users_only|nobody)/
def default_searchability_from_bio?
note = @account.note
return false if note.blank?
note.include?('searchable_by_default_range')
end
def searchability_from_bio
note = @account.note
return nil if note.blank?
searchability_bio = note.scan(SCAN_SEARCHABILITY_FEDIBIRD_RE).first || note.scan(SCAN_SEARCHABILITY_RE).first
return nil unless searchability_bio
searchability = searchability_bio[0]
return nil if searchability.nil?
searchability = :public if %w(public all_users).include?(searchability)
searchability = :private if %w(followers followers_only).include?(searchability)
searchability = :direct if %w(reactors reacted_users_only).include?(searchability)
searchability = :limited if %w(private nobody).include?(searchability)
searchability
end
def searchability_from_audience
if audience_searchable_by.nil?
nil
elsif audience_searchable_by.any? { |uri| ActivityPub::TagManager.instance.public_collection?(uri) }
:public
elsif audience_searchable_by.include?('as:Limited')
:limited
elsif audience_searchable_by.include?(@account.followers_url)
:private
else
:direct
end
end
end end

View file

@ -157,9 +157,10 @@ class StatusReachFinder
end end
def banned_domains_for_misskey_of_status(status) def banned_domains_for_misskey_of_status(status)
return [] if status.public_searchability?
return [] unless (status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || (status.unlisted_visibility? && status.account.user&.setting_reject_unlisted_subscription) return [] unless (status.public_unlisted_visibility? && status.account.user&.setting_reject_public_unlisted_subscription) || (status.unlisted_visibility? && status.account.user&.setting_reject_unlisted_subscription)
from_info = InstanceInfo.where(software: %w(misskey calckey)).pluck(:domain) from_info = InstanceInfo.where(software: %w(misskey calckey cherrypick)).pluck(:domain)
from_domain_block = DomainBlock.where(detect_invalid_subscription: true).pluck(:domain) from_domain_block = DomainBlock.where(detect_invalid_subscription: true).pluck(:domain)
(from_info + from_domain_block).uniq (from_info + from_domain_block).uniq
end end

View file

@ -397,6 +397,7 @@ class Account < ApplicationRecord
end end
def public_settings def public_settings
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
config = { config = {
'noindex' => noindex?, 'noindex' => noindex?,
'noai' => noai?, 'noai' => noai?,

View file

@ -20,10 +20,12 @@ class Circle < ApplicationRecord
has_many :circle_accounts, inverse_of: :circle, dependent: :destroy has_many :circle_accounts, inverse_of: :circle, dependent: :destroy
has_many :accounts, through: :circle_accounts has_many :accounts, through: :circle_accounts
has_many :circle_statuses, inverse_of: :circle, dependent: :destroy
has_many :statuses, through: :circle_statuses
validates :title, presence: true validates :title, presence: true
validates_each :account_id, on: :create do |record, _attr, value| validates_each :account_id, on: :create do |record, _attr, value|
record.errors.add(:base, I18n.t('lists.errors.limit')) if List.where(account_id: value).count >= PER_ACCOUNT_LIMIT record.errors.add(:base, I18n.t('lists.errors.limit')) if Circle.where(account_id: value).count >= PER_ACCOUNT_LIMIT
end end
end end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: circle_statuses
#
# id :bigint(8) not null, primary key
# circle_id :bigint(8)
# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CircleStatus < ApplicationRecord
belongs_to :circle
belongs_to :status
validates :status, uniqueness: { scope: :circle }
validate :account_own_status
private
def account_own_status
errors.add(:status_id, :invalid) unless status.account_id == circle.account_id
end
end

View file

@ -47,6 +47,7 @@ class Form::AdminSettings
streaming_other_servers_emoji_reaction streaming_other_servers_emoji_reaction
enable_emoji_reaction enable_emoji_reaction
check_lts_version_only check_lts_version_only
enable_public_unlisted_visibility
).freeze ).freeze
INTEGER_KEYS = %i( INTEGER_KEYS = %i(
@ -74,6 +75,7 @@ class Form::AdminSettings
streaming_other_servers_emoji_reaction streaming_other_servers_emoji_reaction
enable_emoji_reaction enable_emoji_reaction
check_lts_version_only check_lts_version_only
enable_public_unlisted_visibility
).freeze ).freeze
UPLOAD_KEYS = %i( UPLOAD_KEYS = %i(

View file

@ -13,6 +13,8 @@
# #
class Mention < ApplicationRecord class Mention < ApplicationRecord
include Paginable
belongs_to :account, inverse_of: :mentions belongs_to :account, inverse_of: :mentions
belongs_to :status belongs_to :status

View file

@ -58,7 +58,7 @@ class Status < ApplicationRecord
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility
enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability
enum limited_scope: { none: 0, mutual: 1, circle: 2 }, _suffix: :limited enum limited_scope: { none: 0, mutual: 1, circle: 2, personal: 3 }, _suffix: :limited
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@ -106,6 +106,7 @@ class Status < ApplicationRecord
has_one :poll, inverse_of: :status, dependent: :destroy has_one :poll, inverse_of: :status, dependent: :destroy
has_one :trend, class_name: 'StatusTrend', inverse_of: :status has_one :trend, class_name: 'StatusTrend', inverse_of: :status
has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy has_one :scheduled_expiration_status, inverse_of: :status, dependent: :destroy
has_one :circle_status, inverse_of: :status, dependent: :destroy
validates :uri, uniqueness: true, presence: true, unless: :local? validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? } validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -450,11 +451,13 @@ class Status < ApplicationRecord
class << self class << self
def selectable_visibilities def selectable_visibilities
visibilities.keys - %w(direct limited) vs = visibilities.keys - %w(direct limited)
vs -= %w(public_unlisted) unless Setting.enable_public_unlisted_visibility
vs
end end
def selectable_reblog_visibilities def selectable_reblog_visibilities
%w(unset) + visibilities.keys - %w(direct limited) %w(unset) + selectable_visibilities
end end
def selectable_searchabilities def selectable_searchabilities

View file

@ -24,6 +24,10 @@ class StatusPolicy < ApplicationPolicy
end end
end end
def show_mentioned_users?
owned?
end
def reblog? def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author? !requires_mention? && (!private? || owned?) && show? && !blocking_author?
end end

View file

@ -37,6 +37,7 @@ class InitialStateSerializer < ActiveModel::Serializer
status_page_url: Setting.status_page_url, status_page_url: Setting.status_page_url,
sso_redirect: sso_redirect, sso_redirect: sso_redirect,
dtl_tag: DTL_ENABLED ? DTL_TAG : nil, dtl_tag: DTL_ENABLED ? DTL_TAG : nil,
enable_local_privacy: Setting.enable_public_unlisted_visibility,
} }
if object.current_account if object.current_account

View file

@ -4,6 +4,8 @@ class REST::AccountSerializer < ActiveModel::Serializer
include RoutingHelper include RoutingHelper
include FormattingHelper include FormattingHelper
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at, attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :subscribable, :note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :subscribable,
:followers_count, :following_count, :statuses_count, :last_status_at, :other_settings, :noindex :followers_count, :following_count, :statuses_count, :last_status_at, :other_settings, :noindex

View file

@ -3,6 +3,8 @@
class REST::CustomEmojiSerializer < REST::CustomEmojiSlimSerializer class REST::CustomEmojiSerializer < REST::CustomEmojiSlimSerializer
include RoutingHelper include RoutingHelper
# Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes
attribute :aliases, if: :aliases? attribute :aliases, if: :aliases?
def aliases? def aliases?

View file

@ -3,6 +3,8 @@
class REST::CustomEmojiSlimSerializer < ActiveModel::Serializer class REST::CustomEmojiSlimSerializer < ActiveModel::Serializer
include RoutingHelper include RoutingHelper
# Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes
attributes :shortcode, :url, :static_url, :visible_in_picker attributes :shortcode, :url, :static_url, :visible_in_picker
attribute :category, if: :category_loaded? attribute :category, if: :category_loaded?

View file

@ -108,7 +108,6 @@ class REST::InstanceSerializer < ActiveModel::Serializer
# for third party apps # for third party apps
def fedibird_capabilities def fedibird_capabilities
capabilities = [ capabilities = [
:kmyblue_visibility_public_unlisted,
:enable_wide_emoji, :enable_wide_emoji,
:enable_wide_emoji_reaction, :enable_wide_emoji_reaction,
:kmyblue_searchability, :kmyblue_searchability,
@ -126,6 +125,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
capabilities << :profile_search unless Chewy.enabled? capabilities << :profile_search unless Chewy.enabled?
capabilities << :emoji_reaction if Setting.enable_emoji_reaction capabilities << :emoji_reaction if Setting.enable_emoji_reaction
capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility
capabilities capabilities
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::RelationshipSerializer < ActiveModel::Serializer class REST::RelationshipSerializer < ActiveModel::Serializer
# Please update `app/javascript/mastodon/api_types/relationships.ts` when making changes to the attributes
attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by, attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by,
:blocking, :blocked_by, :muting, :muting_notifications, :blocking, :blocked_by, :muting, :muting_notifications,
:requested, :requested_by, :domain_blocking, :endorsed, :note :requested, :requested_by, :domain_blocking, :endorsed, :note

View file

@ -117,7 +117,6 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
# for third party apps # for third party apps
def fedibird_capabilities def fedibird_capabilities
capabilities = [ capabilities = [
:kmyblue_visibility_public_unlisted,
:enable_wide_emoji, :enable_wide_emoji,
:enable_wide_emoji_reaction, :enable_wide_emoji_reaction,
:kmyblue_searchability, :kmyblue_searchability,
@ -135,6 +134,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
capabilities << :profile_search unless Chewy.enabled? capabilities << :profile_search unless Chewy.enabled?
capabilities << :emoji_reaction if Setting.enable_emoji_reaction capabilities << :emoji_reaction if Setting.enable_emoji_reaction
capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility
capabilities capabilities
end end

View file

@ -10,7 +10,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@activity_json = activity_json @activity_json = activity_json
@json = object_json @json = object_json
@status_parser = ActivityPub::Parser::StatusParser.new(@json) @status_parser = ActivityPub::Parser::StatusParser.new(@json, account: status.account)
@uri = @status_parser.uri @uri = @status_parser.uri
@status = status @status = status
@account = status.account @account = status.account

View file

@ -78,7 +78,7 @@ class PostStatusService < BaseService
@visibility = :direct if @in_reply_to&.limited_visibility? @visibility = :direct if @in_reply_to&.limited_visibility?
@visibility = :limited if %w(mutual circle).include?(@options[:visibility]) @visibility = :limited if %w(mutual circle).include?(@options[:visibility])
@visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced? @visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced?
@visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted @visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted && Setting.enable_public_unlisted_visibility
@limited_scope = @options[:visibility]&.to_sym if @visibility == :limited @limited_scope = @options[:visibility]&.to_sym if @visibility == :limited
@searchability = searchability @searchability = searchability
@searchability = :private if @account.silenced? && @searchability&.to_sym == :public @searchability = :private if @account.silenced? && @searchability&.to_sym == :public
@ -86,6 +86,8 @@ class PostStatusService < BaseService
@scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past? @scheduled_at = nil if scheduled_in_the_past?
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?) @reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
raise ArgumentError if !Setting.enable_public_unlisted_visibility && @visibility == :public_unlisted
load_circle load_circle
overwrite_dtl_post overwrite_dtl_post
process_sensitive_words process_sensitive_words
@ -143,6 +145,8 @@ class PostStatusService < BaseService
process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false) process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? @limited_scope : '', circle: @circle, save_records: false)
safeguard_mentions!(@status) safeguard_mentions!(@status)
@status.limited_scope = :personal if @status.limited_visibility? && !process_mentions_service.mentions?
UpdateStatusExpirationService.new.call(@status) UpdateStatusExpirationService.new.call(@status)
# The following transaction block is needed to wrap the UPDATEs to # The following transaction block is needed to wrap the UPDATEs to
@ -192,9 +196,9 @@ class PostStatusService < BaseService
ProcessReferencesService.call_service(@status, @reference_ids, []) ProcessReferencesService.call_service(@status, @reference_ids, [])
LinkCrawlWorker.perform_async(@status.id) LinkCrawlWorker.perform_async(@status.id)
DistributionWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id) ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.personal_limited?
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
GroupReblogService.new.call(@status) GroupReblogService.new.call(@status) unless @status.personal_limited?
end end
def validate_status! def validate_status!
@ -219,7 +223,7 @@ class PostStatusService < BaseService
end end
def process_mentions_service def process_mentions_service
ProcessMentionsService.new @process_mentions_service ||= ProcessMentionsService.new
end end
def process_hashtags_service def process_hashtags_service

View file

@ -24,6 +24,10 @@ class ProcessMentionsService < BaseService
end end
end end
def mentions?
@current_mentions.present?
end
private private
def scan_text! def scan_text!
@ -112,5 +116,7 @@ class ProcessMentionsService < BaseService
@circle.accounts.find_each do |target_account| @circle.accounts.find_each do |target_account|
@current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) @current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id)
end end
@circle.statuses << @status
end end
end end

View file

@ -167,7 +167,13 @@ class UpdateStatusService < BaseService
def update_metadata! def update_metadata!
ProcessHashtagsService.new.call(@status) ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status) process_mentions_service.call(@status)
@status.update(limited_scope: :circle) if process_mentions_service.mentions?
end
def process_mentions_service
@process_mentions_service ||= ProcessMentionsService.new
end end
def broadcast_updates! def broadcast_updates!

View file

@ -40,6 +40,11 @@
.fields-group .fields-group
= f.input :streaming_other_servers_emoji_reaction, as: :boolean, wrapper: :with_label, kmyblue: true = f.input :streaming_other_servers_emoji_reaction, as: :boolean, wrapper: :with_label, kmyblue: true
%h4= t('admin.settings.discovery.visibilities')
.fields-group
= f.input :enable_public_unlisted_visibility, as: :boolean, wrapper: :with_label, kmyblue: true, hint: false
%h4= t('admin.settings.discovery.publish_statistics') %h4= t('admin.settings.discovery.publish_statistics')
.fields-group .fields-group

View file

@ -21,8 +21,9 @@
.fields-group .fields-group
= ff.input :stay_privacy, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_stay_privacy') = ff.input :stay_privacy, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_stay_privacy')
.fields-group - if Setting.enable_public_unlisted_visibility
= ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted') .fields-group
= ff.input :public_post_to_unlisted, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_public_post_to_unlisted'), hint: I18n.t('simple_form.hints.defaults.setting_public_post_to_unlisted')
.fields-group .fields-group
= ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false = ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false

View file

@ -26,8 +26,9 @@
%p.lead= t('privacy_extra.stop_deliver_hint_html') %p.lead= t('privacy_extra.stop_deliver_hint_html')
= f.simple_fields_for :settings, current_user.settings do |ff| = f.simple_fields_for :settings, current_user.settings do |ff|
.fields-group - if Setting.enable_public_unlisted_visibility
= ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription') .fields-group
= ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription')
.fields-group .fields-group
= ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription') = ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription')

View file

@ -156,7 +156,6 @@ fa:
admin:read:reports: خواندن اطّلاعات حساس از همهٔ گزارش‌ها و حساب‌های گزارش‌شده admin:read:reports: خواندن اطّلاعات حساس از همهٔ گزارش‌ها و حساب‌های گزارش‌شده
admin:write: تغییر تمام داده‌ها روی کارساز admin:write: تغییر تمام داده‌ها روی کارساز
admin:write:accounts: انجام کنش مدیریتی روی حساب‌ها admin:write:accounts: انجام کنش مدیریتی روی حساب‌ها
admin:write:canonical_email_blocks: انجام کنش‌های نظارتی روی همهٔ انسدادهای رایانامهٔ متعارف
admin:write:domain_allows: انجام کنش مدیریتی روی اجازه‌های دامنه admin:write:domain_allows: انجام کنش مدیریتی روی اجازه‌های دامنه
admin:write:domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنه admin:write:domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنه
admin:write:email_domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنهٔ رایانامه admin:write:email_domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنهٔ رایانامه

View file

@ -817,6 +817,7 @@ en:
publish_statistics: Publish statistics publish_statistics: Publish statistics
title: Discovery title: Discovery
trends: Trends trends: Trends
visibilities: Visibilities
domain_blocks: domain_blocks:
all: To everyone all: To everyone
disabled: To no one disabled: To no one

View file

@ -814,6 +814,7 @@ ja:
publish_statistics: 統計情報を公開する publish_statistics: 統計情報を公開する
title: 見つける title: 見つける
trends: トレンド trends: トレンド
visibilities: 公開範囲
domain_blocks: domain_blocks:
all: 誰にでも許可 all: 誰にでも許可
disabled: 誰にも許可しない disabled: 誰にも許可しない

View file

@ -291,11 +291,7 @@ cy:
reblog: Mae rhywun wedi hybu eich postiad reblog: Mae rhywun wedi hybu eich postiad
report: Cyflwynwyd adroddiad newydd report: Cyflwynwyd adroddiad newydd
software_updates: software_updates:
all: Rhoi gwybod am bob ddiweddariad
critical: Rhoi gwybod am ddiweddariadau critigol yn unig
label: Mae fersiwn Mastodon newydd ar gael label: Mae fersiwn Mastodon newydd ar gael
none: Byth rhoi gwybod am ddiweddariadau (nid argymhellir)
patch: Rhoi gwybod am ddiweddariadau trwsio byg
trending_tag: Mae pwnc llosg newydd angen adolygiad trending_tag: Mae pwnc llosg newydd angen adolygiad
rule: rule:
text: Rheol text: Rheol

View file

@ -96,6 +96,7 @@ en:
closed_registrations_message: Displayed when sign-ups are closed closed_registrations_message: Displayed when sign-ups are closed
content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo. content_cache_retention_period: All posts and boosts from other servers will be deleted after the specified number of days. Some posts may not be recoverable. All related bookmarks, favourites and boosts will also be lost and impossible to undo.
custom_css: You can apply custom styles on the web version of Mastodon. custom_css: You can apply custom styles on the web version of Mastodon.
enable_public_unlisted_visibility: If true, your community maybe closed-minded. If turn it false, strongly recommend that you disclose that you have disabled this setting!
mascot: Overrides the illustration in the advanced web interface. mascot: Overrides the illustration in the advanced web interface.
media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand. media_cache_retention_period: Downloaded media files will be deleted after the specified number of days when set to a positive value, and re-downloaded on demand.
peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense.
@ -268,8 +269,8 @@ en:
setting_noai: Set noai meta tags setting_noai: Set noai meta tags
setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app
setting_reduce_motion: Reduce motion in animations setting_reduce_motion: Reduce motion in animations
setting_reject_public_unlisted_subscription: Reject sending public unlisted posts to Misskey, Calckey setting_reject_public_unlisted_subscription: Reject sending public unlisted visibility/non-public searchability posts to Misskey, Calckey
setting_reject_unlisted_subscription: Reject sending unlisted posts to Misskey, Calckey setting_reject_unlisted_subscription: Reject sending unlisted visibility/non-public searchability posts to Misskey, Calckey
setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED] setting_send_without_domain_blocks: Send your post to all server with administrator set as rejecting-post-server for protect you [DEPRECATED]
setting_show_application: Disclose application used to send posts setting_show_application: Disclose application used to send posts
setting_show_emoji_reaction_on_timeline: Show all stamps on timeline setting_show_emoji_reaction_on_timeline: Show all stamps on timeline
@ -321,6 +322,7 @@ en:
content_cache_retention_period: Content cache retention period content_cache_retention_period: Content cache retention period
custom_css: Custom CSS custom_css: Custom CSS
enable_emoji_reaction: Enable stamp function enable_emoji_reaction: Enable stamp function
enable_public_unlisted_visibility: Enable public-unlisted visibility
mascot: Custom mascot (legacy) mascot: Custom mascot (legacy)
media_cache_retention_period: Media cache retention period media_cache_retention_period: Media cache retention period
peers_api_enabled: Publish list of discovered servers in the API peers_api_enabled: Publish list of discovered servers in the API

View file

@ -81,7 +81,7 @@ ja:
setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません
setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません
setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります
setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください setting_reject_unlisted_subscription: Misskeyやそのフォークは、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください
setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります
setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります
setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます
@ -109,6 +109,7 @@ ja:
closed_registrations_message: アカウント作成を停止している時に表示されます closed_registrations_message: アカウント作成を停止している時に表示されます
content_cache_retention_period: 指定した日数が経過した他のサーバーの投稿とブーストを削除します。削除された投稿は再取得できない場合があります。削除された投稿についたブックマークやお気に入り、ブーストも失われ、元に戻せません。 content_cache_retention_period: 指定した日数が経過した他のサーバーの投稿とブーストを削除します。削除された投稿は再取得できない場合があります。削除された投稿についたブックマークやお気に入り、ブーストも失われ、元に戻せません。
custom_css: ウェブ版のMastodonでカスタムスタイルを適用できます。 custom_css: ウェブ版のMastodonでカスタムスタイルを適用できます。
enable_public_unlisted_visibility: 有効にするとあなたのコミュニティは閉鎖的になるかもしれません。この設定はkmyblueの主要機能の1つであり、無効にする場合は概要などに記載することを強くおすすめします。
mascot: 上級者向けWebインターフェースのイラストを上書きします。 mascot: 上級者向けWebインターフェースのイラストを上書きします。
media_cache_retention_period: 正の値に設定されている場合、ダウンロードされたメディアファイルは指定された日数の後に削除され、リクエストに応じて再ダウンロードされます。 media_cache_retention_period: 正の値に設定されている場合、ダウンロードされたメディアファイルは指定された日数の後に削除され、リクエストに応じて再ダウンロードされます。
peers_api_enabled: このサーバーが Fediverse で遭遇したドメイン名のリストです。このサーバーが知っているだけで、特定のサーバーと連合しているかのデータは含まれません。これは一般的に Fediverse に関する統計情報を収集するサービスによって使用されます。 peers_api_enabled: このサーバーが Fediverse で遭遇したドメイン名のリストです。このサーバーが知っているだけで、特定のサーバーと連合しているかのデータは含まれません。これは一般的に Fediverse に関する統計情報を収集するサービスによって使用されます。
@ -283,8 +284,8 @@ ja:
setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する
setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する
setting_reduce_motion: アニメーションの動きを減らす setting_reduce_motion: アニメーションの動きを減らす
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨) setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨)
setting_show_application: 送信したアプリを開示する setting_show_application: 送信したアプリを開示する
setting_show_emoji_reaction_on_timeline: タイムライン上に他の人のつけたスタンプを表示する setting_show_emoji_reaction_on_timeline: タイムライン上に他の人のつけたスタンプを表示する
@ -336,6 +337,7 @@ ja:
content_cache_retention_period: コンテンツキャッシュの保持期間 content_cache_retention_period: コンテンツキャッシュの保持期間
custom_css: カスタムCSS custom_css: カスタムCSS
enable_emoji_reaction: スタンプ機能を有効にする enable_emoji_reaction: スタンプ機能を有効にする
enable_public_unlisted_visibility: 公開範囲「ローカル公開」を有効にする
mascot: カスタムマスコット(レガシー) mascot: カスタムマスコット(レガシー)
media_cache_retention_period: メディアキャッシュの保持期間 media_cache_retention_period: メディアキャッシュの保持期間
peers_api_enabled: 発見したサーバーのリストをAPIで公開する peers_api_enabled: 発見したサーバーのリストをAPIで公開する

View file

@ -18,7 +18,7 @@ Rails.application.routes.draw do
/lists/(*any) /lists/(*any)
/antennasw/(*any) /antennasw/(*any)
/antennast/(*any) /antennast/(*any)
/circles /circles/(*any)
/notifications /notifications
/favourites /favourites
/emoji_reactions /emoji_reactions

View file

@ -12,6 +12,7 @@ namespace :api, format: false do
resources :favourited_by, controller: :favourited_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
resources :referred_by, controller: :referred_by_statuses, only: :index resources :referred_by, controller: :referred_by_statuses, only: :index
resources :mentioned_by, controller: :mentioned_accounts, only: :index
resources :bookmark_categories, only: :index resources :bookmark_categories, only: :index
resource :reblog, only: :create resource :reblog, only: :create
post :unreblog, to: 'reblogs#destroy' post :unreblog, to: 'reblogs#destroy'
@ -226,6 +227,7 @@ namespace :api, format: false do
resources :circles, only: [:index, :create, :show, :update, :destroy] do resources :circles, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts'
resource :statuses, only: [:show], controller: 'circles/statuses'
end end
resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do

View file

@ -42,6 +42,7 @@ defaults: &defaults
streaming_other_servers_emoji_reaction: false streaming_other_servers_emoji_reaction: false
enable_emoji_reaction: true enable_emoji_reaction: true
check_lts_version_only: true check_lts_version_only: true
enable_public_unlisted_visibility: true
development: development:
<<: *defaults <<: *defaults

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class CreateCircleStatuses < ActiveRecord::Migration[7.0]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def change
safety_assured do
create_table :circle_statuses do |t|
t.belongs_to :circle, null: true, foreign_key: { on_delete: :cascade }
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
add_index :circle_statuses, [:circle_id, :status_id], unique: true
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_19_232836) do ActiveRecord::Schema[7.0].define(version: 2023_09_23_103430) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -447,6 +447,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_19_232836) do
t.index ["follow_id"], name: "index_circle_accounts_on_follow_id" t.index ["follow_id"], name: "index_circle_accounts_on_follow_id"
end end
create_table "circle_statuses", force: :cascade do |t|
t.bigint "circle_id"
t.bigint "status_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["circle_id", "status_id"], name: "index_circle_statuses_on_circle_id_and_status_id", unique: true
t.index ["circle_id"], name: "index_circle_statuses_on_circle_id"
t.index ["status_id"], name: "index_circle_statuses_on_status_id"
end
create_table "circles", force: :cascade do |t| create_table "circles", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.string "title", default: "", null: false t.string "title", default: "", null: false
@ -1414,6 +1424,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_19_232836) do
add_foreign_key "circle_accounts", "accounts", on_delete: :cascade add_foreign_key "circle_accounts", "accounts", on_delete: :cascade
add_foreign_key "circle_accounts", "circles", on_delete: :cascade add_foreign_key "circle_accounts", "circles", on_delete: :cascade
add_foreign_key "circle_accounts", "follows", on_delete: :cascade add_foreign_key "circle_accounts", "follows", on_delete: :cascade
add_foreign_key "circle_statuses", "circles", on_delete: :cascade
add_foreign_key "circle_statuses", "statuses", on_delete: :cascade
add_foreign_key "circles", "accounts", on_delete: :cascade add_foreign_key "circles", "accounts", on_delete: :cascade
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade

View file

@ -5,15 +5,15 @@ module Mastodon
module_function module_function
def kmyblue_major def kmyblue_major
5 6
end end
def kmyblue_minor def kmyblue_minor
2 0
end end
def kmyblue_flag def kmyblue_flag
'LTS' nil # 'LTS'
end end
def major def major

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Circles::StatusesController do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:lists') }
let(:circle) { Fabricate(:circle, account: user.account) }
let(:status) { Fabricate(:status, account: user.account, visibility: 'limited', limited_scope: 'circle') }
before do
allow(controller).to receive(:doorkeeper_token) { token }
Fabricate(:circle_status, status: status, circle: circle)
other_circle = Fabricate(:circle)
Fabricate(:circle_status, status: Fabricate(:status, visibility: 'limited', limited_scope: 'circle', account: other_circle.account), circle: other_circle)
end
describe 'GET #index' do
it 'returns http success' do
get :show, params: { circle_id: circle.id, limit: 5 }
expect(response).to have_http_status(200)
json = body_as_json
expect(json.map { |item| item[:id].to_i }).to eq [status.id]
end
context "with someone else's statuses" do
let(:other_account) { Fabricate(:account) }
let(:other_circle) { Fabricate(:circle, account: other_account) }
before do
Fabricate(:circle_status, circle: other_circle, status: Fabricate(:status, account: other_account, visibility: 'limited', limited_scope: 'circle'))
end
it 'returns http failed' do
get :show, params: { circle_id: other_circle.id }
expect(response).to have_http_status(404)
end
end
end
end

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::Statuses::MentionedAccountsController do
render_views
let(:user) { Fabricate(:user) }
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') }
let(:alice) { Fabricate(:account) }
let(:bob) { Fabricate(:account) }
let(:ohagi) { Fabricate(:account) }
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:status) { Fabricate(:status, account: user.account) }
before do
Mention.create!(account: bob, status: status)
Mention.create!(account: ohagi, status: status)
end
it 'returns http success' do
get :index, params: { status_id: status.id, limit: 2 }
expect(response).to have_http_status(200)
expect(response.headers['Link'].links.size).to eq(2)
end
it 'returns accounts who favorited the status' do
get :index, params: { status_id: status.id, limit: 2 }
expect(body_as_json.size).to eq 2
expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(bob.id.to_s, ohagi.id.to_s)
end
it 'does not return blocked users' do
user.account.block!(ohagi)
get :index, params: { status_id: status.id, limit: 2 }
expect(body_as_json.size).to eq 1
expect(body_as_json[0][:id]).to eq bob.id.to_s
end
context 'when other accounts status' do
let(:status) { Fabricate(:status, account: alice) }
it 'returns http unauthorized' do
get :index, params: { status_id: status.id }
expect(response).to have_http_status(404)
end
end
end
end
context 'without an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token).and_return(nil)
end
context 'with a public status' do
let(:status) { Fabricate(:status, account: user.account, visibility: :public) }
describe 'GET #index' do
before do
Mention.create!(account: bob, status: status)
end
it 'returns http unauthorized' do
get :index, params: { status_id: status.id }
expect(response).to have_http_status(404)
end
end
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:circle_status) do
circle
status
before_create { |circle_status, _| circle_status.status.account = circle.account }
end

View file

@ -1088,6 +1088,86 @@ RSpec.describe ActivityPub::Activity::Create do
expect(poll.votes.first).to be_nil expect(poll.votes.first).to be_nil
end end
end end
context 'with language' do
let(:to) { 'https://www.w3.org/ns/activitystreams#Public' }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: to,
contentMap: { ja: 'Lorem ipsum' },
}
end
it 'create status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.language).to eq 'ja'
end
end
context 'without language' do
let(:to) { 'https://www.w3.org/ns/activitystreams#Public' }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: to,
}
end
it 'create status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.language).to be_nil
end
end
context 'without language when misskey server' do
let(:sender_software) { 'misskey' }
let(:to) { 'https://www.w3.org/ns/activitystreams#Public' }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: to,
}
end
it 'create status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.language).to eq 'ja'
end
end
context 'with language when misskey server' do
let(:sender_software) { 'misskey' }
let(:to) { 'https://www.w3.org/ns/activitystreams#Public' }
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
to: to,
contentMap: { 'en-US': 'Lorem ipsum' },
}
end
it 'create status' do
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.language).to eq 'en-US'
end
end
end end
context 'with an encrypted message' do context 'with an encrypted message' do

View file

@ -9,8 +9,9 @@ describe StatusReachFinder do
let(:parent_status) { nil } let(:parent_status) { nil }
let(:visibility) { :public } let(:visibility) { :public }
let(:searchability) { :public }
let(:alice) { Fabricate(:account, username: 'alice') } let(:alice) { Fabricate(:account, username: 'alice') }
let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) } let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility, searchability: searchability) }
context 'with a simple case' do context 'with a simple case' do
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
@ -49,8 +50,9 @@ describe StatusReachFinder do
end end
end end
context 'when misskey' do context 'when misskey with private searchability' do
let(:sender_software) { 'misskey' } let(:sender_software) { 'misskey' }
let(:searchability) { :private }
it 'send status without setting' do it 'send status without setting' do
expect(subject.inboxes).to include 'https://foo.bar/inbox' expect(subject.inboxes).to include 'https://foo.bar/inbox'
@ -63,6 +65,16 @@ describe StatusReachFinder do
expect(subject.inboxes_for_misskey).to include 'https://foo.bar/inbox' expect(subject.inboxes_for_misskey).to include 'https://foo.bar/inbox'
end end
end end
context 'when misskey with public searchability' do
let(:sender_software) { 'misskey' }
it 'send status with setting' do
alice.user.settings.update(reject_unlisted_subscription: 'true')
expect(subject.inboxes).to include 'https://foo.bar/inbox'
expect(subject.inboxes_for_misskey).to_not include 'https://foo.bar/inbox'
end
end
end end
context 'when it contains mentions of remote accounts' do context 'when it contains mentions of remote accounts' do

View file

@ -188,6 +188,17 @@ RSpec.describe PostStatusService, type: :service do
expect(status.mentioned_accounts.first.id).to eq mutual_account.id expect(status.mentioned_accounts.first.id).to eq mutual_account.id
end end
it 'personal visibility with mutual' do
account = Fabricate(:account)
text = 'This is an English text.'
status = subject.call(account, text: text, visibility: 'mutual')
expect(status.visibility).to eq 'limited'
expect(status.limited_scope).to eq 'personal'
expect(status.mentioned_accounts.count).to eq 0
end
it 'circle visibility' do it 'circle visibility' do
account = Fabricate(:account) account = Fabricate(:account)
circle_account = Fabricate(:account) circle_account = Fabricate(:account)
@ -227,6 +238,31 @@ RSpec.describe PostStatusService, type: :service do
expect { subject.call(account, text: text, visibility: 'limited') }.to raise_exception ActiveRecord::RecordInvalid expect { subject.call(account, text: text, visibility: 'limited') }.to raise_exception ActiveRecord::RecordInvalid
end end
it 'personal visibility with circle' do
account = Fabricate(:account)
circle = Fabricate(:circle, account: account)
text = 'This is an English text.'
status = subject.call(account, text: text, visibility: 'circle', circle_id: circle.id)
expect(status.visibility).to eq 'limited'
expect(status.limited_scope).to eq 'personal'
expect(status.mentioned_accounts.count).to eq 0
end
it 'using empty circle but with mention' do
account = Fabricate(:account)
Fabricate(:account, username: 'bob', domain: nil)
circle = Fabricate(:circle, account: account)
text = 'This is an English text. @bob'
status = subject.call(account, text: text, visibility: 'circle', circle_id: circle.id)
expect(status.visibility).to eq 'limited'
expect(status.limited_scope).to eq 'circle'
expect(status.mentioned_accounts.count).to eq 1
end
it 'safeguards mentions' do it 'safeguards mentions' do
account = Fabricate(:account) account = Fabricate(:account)
mentioned_account = Fabricate(:account, username: 'alice') mentioned_account = Fabricate(:account, username: 'alice')
@ -270,6 +306,19 @@ RSpec.describe PostStatusService, type: :service do
expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id) expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id)
end end
it 'gets distributed when personal post' do
allow(DistributionWorker).to receive(:perform_async)
allow(ActivityPub::DistributionWorker).to receive(:perform_async)
account = Fabricate(:account)
empty_circle = Fabricate(:circle, account: account)
status = subject.call(account, text: 'test status update', visibility: 'circle', circle_id: empty_circle.id)
expect(DistributionWorker).to have_received(:perform_async).with(status.id)
expect(ActivityPub::DistributionWorker).to_not have_received(:perform_async).with(status.id)
end
it 'crawls links' do it 'crawls links' do
allow(LinkCrawlWorker).to receive(:perform_async) allow(LinkCrawlWorker).to receive(:perform_async)
account = Fabricate(:account) account = Fabricate(:account)

View file

@ -103,4 +103,27 @@ RSpec.describe ProcessMentionsService, type: :service do
end end
end end
end end
context 'with circle post' do
let(:status) { Fabricate(:status, account: account) }
let(:circle) { Fabricate(:circle, account: account) }
let(:follower) { Fabricate(:account) }
let(:other) { Fabricate(:account) }
before do
follower.follow!(account)
other.follow!(account)
circle.accounts << follower
described_class.new.call(status, limited_type: :circle, circle: circle)
end
it 'remains circle post on history' do
expect(CircleStatus.exists?(circle_id: circle.id, status_id: status.id)).to be true
end
it 'post is delivered to circle members' do
expect(status.mentioned_accounts.count).to eq 1
expect(status.mentioned_accounts.first.id).to eq follower.id
end
end
end end

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