Merge commit '322ce1fc27
' into kb_patch
This commit is contained in:
commit
1ac499aefe
102 changed files with 1827 additions and 495 deletions
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
|
@ -1,3 +1 @@
|
|||
patreon: mastodon
|
||||
open_collective: mastodon
|
||||
custom: https://sponsor.joinmastodon.org
|
||||
custom: https://fantia.jp/fanclubs/484677
|
||||
|
|
74
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
74
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal 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
|
76
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
76
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -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
|
16
.github/ISSUE_TEMPLATE/2.feature_request.yml
vendored
Normal file
16
.github/ISSUE_TEMPLATE/2.feature_request.yml
vendored
Normal 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
|
65
.github/ISSUE_TEMPLATE/2.server_bug_report.yml
vendored
65
.github/ISSUE_TEMPLATE/2.server_bug_report.yml
vendored
|
@ -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
|
22
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
22
.github/ISSUE_TEMPLATE/3.feature_request.yml
vendored
|
@ -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
|
28
.github/ISSUE_TEMPLATE/3.spec_change_request.yml
vendored
Normal file
28
.github/ISSUE_TEMPLATE/3.spec_change_request.yml
vendored
Normal 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
|
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
@ -1,5 +1 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: GitHub Discussions
|
||||
url: https://github.com/mastodon/mastodon/discussions
|
||||
about: Please ask and answer questions here.
|
||||
blank_issues_enabled: true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
# 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 node:${NODE_VERSION} as build
|
||||
|
|
|
@ -688,7 +688,7 @@ GEM
|
|||
scenic (1.7.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
selenium-webdriver (4.11.0)
|
||||
selenium-webdriver (4.13.1)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
|
@ -806,7 +806,7 @@ GEM
|
|||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
websocket (1.2.9)
|
||||
websocket (1.2.10)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
|
22
README.md
22
README.md
|
@ -37,16 +37,34 @@ RAILS_ENV=test ES_ENABLED=true RUN_SEARCH_SPECS=true bundle exec rspec spec/sear
|
|||
|
||||
## kmyblueの強み
|
||||
|
||||
追加の詳細は下記記事もご覧ください。
|
||||
|
||||
https://note.com/kmycode/n/n5fd5e823ed40
|
||||
|
||||
### 本家Mastodonへの積極的追従
|
||||
|
||||
kmyblueは、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家Mastodonに積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。
|
||||
kmyblueの追加機能そのままに、Mastodonの新機能も利用できるよう調整を行います。
|
||||
|
||||
### ゆるやかな内輪での運用
|
||||
|
||||
kmyblueは同人向けサーバーとして出発したため、同人作家に需要のある「内輪ノリを外部にできるだけもらさない」という部分に特化しています。
|
||||
|
||||
「ローカル公開」という機能によって、「ローカルタイムラインに流すが他のサーバーの連合タイムラインに流さない」投稿が可能です。ただしMisskeyのローカル限定とは異なり、他のサーバーのフォロワーのタイムラインにも投稿は流れます。自分のサーバーの中で内輪で盛り上がって、他のサーバーの連合タイムラインには外面だけの投稿を流すことも可能です。
|
||||
|
||||
また、通常のMastodonでは公開投稿を他のサーバーの人に自由に検索できるようにすることも可能ですが、kmyblueでは未収載投稿に対して同様の設定が可能です。つまり、ローカルタイムラインにも連合タイムラインにも流れない、誰かの目に自然に触れることはない、でも特定キーワードを使った検索では引っかかりたい、そのような需要に対応できます。ただしこの検索ができるのはMisskeyならびにkmyblueフォークだけです。
|
||||
|
||||
### 絵文字リアクション対応
|
||||
|
||||
kmyblueは絵文字リアクションに対応しているフォークの1つです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。絵文字リアクションによってユーザーは「お気に入り」以上「返信」以下のコミュニケーションを気軽に行うことができ、Mastodonの利用体験が向上します。
|
||||
各ユーザーが自分の投稿に絵文字リアクションをつけることを拒否できるほか、サーバー全体として絵文字リアクションを無効にする設定も可能です(この場合、他サーバーから来た絵文字リアクションはお気に入りとして保存されます)
|
||||
|
||||
### プライバシーへの配慮
|
||||
|
||||
- **ローカル公開** - ローカルタイムラインにのみ投稿を流し、他サーバーの連合タイムラインに流しません。他のサーバーには未収載として配信されます
|
||||
- **検索許可** - 投稿ごとに検索を許可する範囲を細かく制御できます。これは本家Mastodonにはない特徴です
|
||||
- **Misskeyへの投稿配送制限** - Misskeyへ未収載投稿を配送する時、「フォロワーのみ」に変換する設定がユーザー個別に可能です。Misskeyの自由な検索からkmyblue上の投稿を保護します
|
||||
|
||||
## kmyblueのブランチ
|
||||
|
||||
- **main** - 管理者が本家MastodonにPRするときに使うことがあります
|
||||
|
@ -58,7 +76,9 @@ kmyblueは絵文字リアクションに対応しているフォークの1つ
|
|||
|
||||
## 本家Mastodonからの追加機能
|
||||
|
||||
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。
|
||||
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。ただし以下はあくまで一例です。ほぼ完全な一覧は、以下の記事を参照してください。
|
||||
|
||||
https://note.com/kmycode/n/n5fd5e823ed40
|
||||
|
||||
### ローカル公開
|
||||
|
||||
|
|
65
app/controllers/api/v1/circles/statuses_controller.rb
Normal file
65
app/controllers/api/v1/circles/statuses_controller.rb
Normal 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
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
import api from '../api';
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
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_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_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) => {
|
||||
if (getState().getIn(['circles', id])) {
|
||||
return;
|
||||
|
@ -370,3 +378,89 @@ export const removeFromCircleAdder = circleId => (dispatch, getState) => {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,8 @@ export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
|
|||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||
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_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
|
@ -174,6 +176,7 @@ export function submitCompose(routerHistory) {
|
|||
const status = getState().getIn(['compose', 'text'], '');
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const statusId = getState().getIn(['compose', 'id'], null);
|
||||
const circleId = getState().getIn(['compose', 'circle_id'], null);
|
||||
|
||||
if ((!status || !status.length) && media.size === 0) {
|
||||
return;
|
||||
|
@ -253,6 +256,10 @@ export function submitCompose(routerHistory) {
|
|||
insertIfOnline(`account:${response.data.account.id}`);
|
||||
}
|
||||
|
||||
if (statusId === null && circleId !== null && circleId !== 0) {
|
||||
dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId));
|
||||
}
|
||||
|
||||
dispatch(showAlert({
|
||||
message: statusId === null ? messages.published : messages.saved,
|
||||
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) {
|
||||
return {
|
||||
type: COMPOSE_SUBMIT_FAIL,
|
||||
|
|
|
@ -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 };
|
||||
}
|
11
app/javascript/mastodon/actions/dropdown_menu.ts
Normal file
11
app/javascript/mastodon/actions/dropdown_menu.ts
Normal 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',
|
||||
);
|
|
@ -71,6 +71,14 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
|
|||
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
|
||||
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) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(reblogRequest(status));
|
||||
|
@ -735,3 +743,85 @@ export function unpinFail(status, error) {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ModalProps } from 'mastodon/reducers/modal';
|
||||
|
||||
import type { MODAL_COMPONENTS } from '../features/ui/components/modal_root';
|
||||
|
||||
export type ModalType = keyof typeof MODAL_COMPONENTS;
|
||||
|
||||
interface OpenModalPayload {
|
||||
modalType: ModalType;
|
||||
modalProps: unknown;
|
||||
modalProps: ModalProps;
|
||||
}
|
||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||
|
||||
|
|
65
app/javascript/mastodon/api_types/accounts.ts
Normal file
65
app/javascript/mastodon/api_types/accounts.ts
Normal 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;
|
||||
}
|
12
app/javascript/mastodon/api_types/custom_emoji.ts
Normal file
12
app/javascript/mastodon/api_types/custom_emoji.ts
Normal 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[];
|
||||
}
|
18
app/javascript/mastodon/api_types/relationships.ts
Normal file
18
app/javascript/mastodon/api_types/relationships.ts
Normal 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;
|
||||
}
|
|
@ -4,9 +4,14 @@ import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_m
|
|||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import DropdownMenu from 'mastodon/components/dropdown_menu';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
|
||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
items: state.getIn(['history', statusId, 'items']),
|
||||
loading: state.getIn(['history', statusId, 'loading']),
|
||||
});
|
||||
|
@ -15,11 +20,11 @@ const mapDispatchToProps = (dispatch, { statusId }) => ({
|
|||
|
||||
onOpen (id, onItemClick, keyboard) {
|
||||
dispatch(fetchHistory(statusId));
|
||||
dispatch(openDropdownMenu(id, keyboard));
|
||||
dispatch(openDropdownMenu({ id, keyboard }));
|
||||
},
|
||||
|
||||
onClose (id) {
|
||||
dispatch(closeDropdownMenu(id));
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
@ -23,9 +23,14 @@ const MOUSE_IDLE_DELAY = 300;
|
|||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { scrollKey }) => {
|
||||
return {
|
||||
preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
|
||||
preventScroll: scrollKey === state.dropdownMenu.scrollKey,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -73,6 +73,7 @@ const messages = defineMessages({
|
|||
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
|
||||
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers 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' },
|
||||
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) },
|
||||
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_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) },
|
||||
};
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ const messages = defineMessages({
|
|||
edit: { id: 'status.edit', defaultMessage: 'Edit' },
|
||||
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
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')}`);
|
||||
};
|
||||
|
||||
handleOpenMentions = () => {
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`);
|
||||
};
|
||||
|
||||
handleEmbed = () => {
|
||||
this.props.onEmbed(this.props.status);
|
||||
};
|
||||
|
@ -315,7 +320,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (!simpleTimelineMenu) {
|
||||
if (writtenByMe) {
|
||||
menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions });
|
||||
}
|
||||
|
||||
if (!simpleTimelineMenu || writtenByMe) {
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,9 +7,12 @@ import { openModal, closeModal } from '../actions/modal';
|
|||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
/**
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
*/
|
||||
const mapStateToProps = state => ({
|
||||
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
|
||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||
|
@ -25,7 +28,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
|||
actions: items,
|
||||
onClick: onItemClick,
|
||||
},
|
||||
}) : openDropdownMenu(id, keyboard, scrollKey));
|
||||
}) : openDropdownMenu({ id, keyboard, scrollKey }));
|
||||
},
|
||||
|
||||
onClose(id) {
|
||||
|
@ -33,7 +36,7 @@ const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
|||
modalType: 'ACTIONS',
|
||||
ignoreFocus: false,
|
||||
}));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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.' },
|
||||
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.' },
|
||||
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 = {
|
||||
|
@ -122,6 +127,10 @@ class About extends PureComponent {
|
|||
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
|
||||
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 (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
|
@ -182,6 +191,20 @@ class About extends PureComponent {
|
|||
))}
|
||||
</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}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
<>
|
||||
|
|
182
app/javascript/mastodon/features/circle_statuses/index.jsx
Normal file
182
app/javascript/mastodon/features/circle_statuses/index.jsx
Normal 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));
|
|
@ -13,7 +13,6 @@ import { fetchCircles, deleteCircle } from 'mastodon/actions/circles';
|
|||
import { openModal } from 'mastodon/actions/modal';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import ColumnLink from 'mastodon/features/ui/components/column_link';
|
||||
|
@ -106,10 +105,7 @@ class Circles extends ImmutablePureComponent {
|
|||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{circles.map(circle =>
|
||||
(<div key={circle.get('id')} className='circle-item'>
|
||||
<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>)
|
||||
<ColumnLink key={circle.get('id')} to={`/circles/${circle.get('id')}`} icon='user-circle' text={circle.get('title')} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
|
|
|
@ -103,11 +103,11 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
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 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) => {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { supportsPassiveEvents } from 'detect-passive-events';
|
|||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
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';
|
||||
|
||||
|
@ -246,6 +246,10 @@ class PrivacyDropdown extends PureComponent {
|
|||
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) {
|
||||
this.selectableOptions = this.selectableOptions.filter((opt) => opt.value !== 'direct');
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { PureComponent } from 'react';
|
|||
const iconStyle = {
|
||||
height: null,
|
||||
lineHeight: '27px',
|
||||
width: `${18 * 1.28571429}px`,
|
||||
minWidth: `${18 * 1.28571429}px`,
|
||||
};
|
||||
|
||||
export default class TextIconButton extends PureComponent {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { changeCircle } from '../../../actions/compose';
|
|||
import CircleSelect from '../components/circle_select';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
unavailable: state.getIn(['compose', 'privacy']) !== 'circle',
|
||||
unavailable: state.getIn(['compose', 'privacy']) !== 'circle' || !!state.getIn(['compose', 'id']),
|
||||
circles: state.get('circles'),
|
||||
circleId: state.getIn(['compose', 'circle_id']),
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
||||
import { MENTION_PATTERN_REGEX } from 'mastodon/utils/mentions';
|
||||
|
||||
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'])),
|
||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||
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) {
|
||||
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.' />} />;
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
directMessageWarning: PropTypes.bool,
|
||||
searchabilityWarning: PropTypes.bool,
|
||||
mentionWarning: PropTypes.bool,
|
||||
limitedPostWarning: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
|
90
app/javascript/mastodon/features/mentioned_users/index.jsx
Normal file
90
app/javascript/mastodon/features/mentioned_users/index.jsx
Normal 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));
|
|
@ -23,6 +23,7 @@ const messages = defineMessages({
|
|||
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
|
||||
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers 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' },
|
||||
});
|
||||
|
||||
|
@ -57,6 +58,7 @@ class StatusCheckBox extends PureComponent {
|
|||
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
|
||||
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_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) },
|
||||
};
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ const messages = defineMessages({
|
|||
edit: { id: 'status.edit', defaultMessage: 'Edit' },
|
||||
direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
|
@ -95,6 +96,10 @@ class ActionBar extends PureComponent {
|
|||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleOpenMentions = () => {
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`);
|
||||
};
|
||||
|
||||
handleReplyClick = () => {
|
||||
this.props.onReply(this.props.status);
|
||||
};
|
||||
|
@ -264,6 +269,7 @@ class ActionBar extends PureComponent {
|
|||
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(null);
|
||||
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
|
||||
|
|
|
@ -35,6 +35,7 @@ const messages = defineMessages({
|
|||
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
|
||||
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers 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' },
|
||||
searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
|
||||
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) },
|
||||
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_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) },
|
||||
};
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ const messages = defineMessages({
|
|||
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
|
||||
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers 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' },
|
||||
});
|
||||
|
||||
|
@ -100,6 +101,7 @@ class BoostModal extends ImmutablePureComponent {
|
|||
'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) },
|
||||
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_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) },
|
||||
};
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
BookmarkCategoryStatuses,
|
||||
AntennaSetting,
|
||||
AntennaTimeline,
|
||||
CircleStatuses,
|
||||
} from '../util/async-components';
|
||||
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
|
@ -45,6 +46,7 @@ const componentMap = {
|
|||
'EMOJI_REACTIONS': EmojiReactedStatuses,
|
||||
'BOOKMARKS': BookmarkedStatuses,
|
||||
'BOOKMARKS_EX': BookmarkCategoryStatuses,
|
||||
'CIRCLE_STATUSES': CircleStatuses,
|
||||
'ANTENNA': AntennaSetting,
|
||||
'ANTENNA_TIMELINE': AntennaTimeline,
|
||||
'LIST': ListTimeline,
|
||||
|
|
|
@ -46,6 +46,7 @@ import {
|
|||
Favourites,
|
||||
EmojiReactions,
|
||||
StatusReferences,
|
||||
MentionedUsers,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
AntennaTimeline,
|
||||
|
@ -65,6 +66,7 @@ import {
|
|||
Lists,
|
||||
Antennas,
|
||||
Circles,
|
||||
CircleStatuses,
|
||||
AntennaSetting,
|
||||
Directory,
|
||||
Explore,
|
||||
|
@ -90,7 +92,7 @@ const mapStateToProps = state => ({
|
|||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 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,
|
||||
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
|
||||
dropdownMenuIsOpen: state.dropdownMenu.openId !== null,
|
||||
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
|
||||
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/emoji_reactions' component={EmojiReactions} 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 */}
|
||||
<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='/lists' component={Lists} content={children} />
|
||||
<WrappedRoute path='/antennasw' component={Antennas} content={children} />
|
||||
<WrappedRoute path='/circles/:id' component={CircleStatuses} content={children} />
|
||||
<WrappedRoute path='/circles' component={Circles} content={children} />
|
||||
|
||||
<Route component={BundleColumnError} />
|
||||
|
|
|
@ -54,6 +54,10 @@ export function Circles () {
|
|||
return import(/* webpackChunkName: "features/circles" */'../../circles');
|
||||
}
|
||||
|
||||
export function CircleStatuses () {
|
||||
return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses');
|
||||
}
|
||||
|
||||
export function Status () {
|
||||
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||
}
|
||||
|
@ -102,6 +106,10 @@ export function StatusReferences () {
|
|||
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
|
||||
}
|
||||
|
||||
export function MentionedUsers () {
|
||||
return import(/* webpackChunkName: "features/mentioned_users" */'../../mentioned_users');
|
||||
}
|
||||
|
||||
export function FollowRequests () {
|
||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
* @property {string} dtl_tag
|
||||
* @property {boolean} enable_emoji_reaction
|
||||
* @property {boolean} enable_login_privacy
|
||||
* @property {boolean} enable_local_privacy
|
||||
* @property {boolean} enable_dtl_menu
|
||||
* @property {boolean=} expand_spoilers
|
||||
* @property {boolean} hide_recent_emojis
|
||||
|
@ -130,6 +131,7 @@ export const displayMediaExpand = getMeta('display_media_expand');
|
|||
export const domain = getMeta('domain');
|
||||
export const dtlTag = getMeta('dtl_tag');
|
||||
export const enableEmojiReaction = getMeta('enable_emoji_reaction');
|
||||
export const enableLocalPrivacy = getMeta('enable_local_privacy');
|
||||
export const enableLoginPrivacy = getMeta('enable_login_privacy');
|
||||
export const enableDtlMenu = getMeta('enable_dtl_menu');
|
||||
export const expandSpoilers = getMeta('expand_spoilers');
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Moderated servers",
|
||||
"about.contact": "Contact:",
|
||||
"about.disabled": "Disabled",
|
||||
"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.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.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.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.powered_by": "Decentralized social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
|
@ -104,6 +107,8 @@
|
|||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this component.",
|
||||
"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_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",
|
||||
|
@ -151,6 +156,7 @@
|
|||
"compose_form.lock_disclaimer.lock": "locked",
|
||||
"compose_form.markdown.marked": "Markdown is 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.searchability_warning": "Self only searchability is not available other mastodon servers. Others can search your post.",
|
||||
"compose_form.poll.add_option": "Add a choice",
|
||||
|
@ -236,6 +242,7 @@
|
|||
"empty_column.account_unavailable": "Profile unavailable",
|
||||
"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.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.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.",
|
||||
|
@ -533,6 +540,7 @@
|
|||
"privacy.login.short": "Login only",
|
||||
"privacy.mutual.long": "Mutual followers only",
|
||||
"privacy.mutual.short": "Mutual",
|
||||
"privacy.personal.short": "Yourself only",
|
||||
"privacy.private.long": "Visible for followers only",
|
||||
"privacy.private.short": "Followers only",
|
||||
"privacy.public.long": "Visible for all",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "制限中のサーバー",
|
||||
"about.contact": "連絡先",
|
||||
"about.disabled": "無効",
|
||||
"about.disclaimer": "Mastodonは自由なオープンソースソフトウェアであり、Mastodon gGmbHの商標です。",
|
||||
"about.domain_blocks.no_reason_available": "理由未記載",
|
||||
"about.domain_blocks.preamble": "Mastodonでは原則的にあらゆるサーバー同士で交流したり、互いの投稿を読んだりできますが、当サーバーでは例外的に次のような制限を設けています。",
|
||||
|
@ -10,6 +11,8 @@
|
|||
"about.domain_blocks.silenced.title": "制限",
|
||||
"about.domain_blocks.suspended.explanation": "これらのサーバーからのデータは処理されず、保存や変換もされません。該当するユーザーとの交流もできません。",
|
||||
"about.domain_blocks.suspended.title": "停止中",
|
||||
"about.enabled": "有効",
|
||||
"about.kmyblue_capability": "このサーバーは、kmyblueというMastodonフォークを利用しています。kmyblue独自機能の一部は、サーバー管理者によって有効・無効を切り替えることができます。",
|
||||
"about.not_available": "この情報はこのサーバーでは利用できません。",
|
||||
"about.powered_by": "{mastodon}による分散型ソーシャルメディア",
|
||||
"about.rules": "サーバーのルール",
|
||||
|
@ -154,8 +157,10 @@
|
|||
"bundle_modal_error.close": "閉じる",
|
||||
"bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。",
|
||||
"bundle_modal_error.retry": "再試行",
|
||||
"circles.account.add": "おはぎに追加",
|
||||
"circles.account.remove": "おはぎから外す",
|
||||
"circles.account.add": "サークルに追加",
|
||||
"circles.account.remove": "サークルから外す",
|
||||
"circles.delete": "サークルを削除",
|
||||
"circles.edit": "サークルを編集",
|
||||
"circles.edit.submit": "タイトルを変更",
|
||||
"circles.new.create": "サークルを作成",
|
||||
"circles.new.title_placeholder": "新規サークル名",
|
||||
|
@ -213,6 +218,7 @@
|
|||
"compose_form.lock_disclaimer.lock": "承認制",
|
||||
"compose_form.markdown.marked": "Markdown有効",
|
||||
"compose_form.markdown.unmarked": "Markdownは有効になっていません",
|
||||
"compose_form.mention_warning": "限定投稿にメンションを追加すると、そのアカウントはサークルメンバー・相互などに関係なくこの投稿を読むことができます",
|
||||
"compose_form.placeholder": "今なにしてる?",
|
||||
"compose_form.searchability_warning": "検索許可「自分のみ」はkmyblue内の検索でのみ有効です。他のサーバーでは「リアクションした人のみ」と同等に扱われます",
|
||||
"compose_form.poll.add_option": "追加",
|
||||
|
@ -308,6 +314,7 @@
|
|||
"empty_column.bookmark_categories": "まだ分類がありません。分類を作るとここに表示されます。",
|
||||
"empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。",
|
||||
"empty_column.circles": "まだサークルがありません。サークルを作るとここに表示されます。",
|
||||
"empty_column.circle_statuses": "まだサークル投稿がありません。このサークルでなにか投稿するとここに表示されます。",
|
||||
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
|
||||
"empty_column.direct": "非公開の返信はまだありません。非公開でやりとりをするとここに表示されます。",
|
||||
"empty_column.domain_blocks": "ブロックしているドメインはありません。",
|
||||
|
@ -618,6 +625,7 @@
|
|||
"privacy.login.short": "ログインユーザーのみ",
|
||||
"privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿",
|
||||
"privacy.mutual.short": "相互のみ",
|
||||
"privacy.personal.short": "自分限定",
|
||||
"privacy.private.long": "フォロワーのみ閲覧可",
|
||||
"privacy.private.short": "フォロワーのみ",
|
||||
"privacy.public.long": "誰でも閲覧可、ホーム+ローカル+連合TL",
|
||||
|
|
|
@ -48,11 +48,11 @@
|
|||
"account.media": "Médiá",
|
||||
"account.mention": "Spomeň @{name}",
|
||||
"account.moved_to": "{name} uvádza, že jeho/jej nový účet je teraz:",
|
||||
"account.mute": "Stíš @{name}",
|
||||
"account.mute_notifications_short": "Stíš oznámenia",
|
||||
"account.mute_short": "Stíš",
|
||||
"account.muted": "Stíšený",
|
||||
"account.no_bio": "Nie je uvedený žiadny popis.",
|
||||
"account.mute": "Nevšímaj si @{name}",
|
||||
"account.mute_notifications_short": "Stíš oboznámenia",
|
||||
"account.mute_short": "Nevšímaj si",
|
||||
"account.muted": "Nevšímaný/á",
|
||||
"account.no_bio": "Nieje uvedený žiadny popis.",
|
||||
"account.open_original_page": "Otvor pôvodnú stránku",
|
||||
"account.posts": "Príspevky",
|
||||
"account.posts_with_replies": "Príspevky a odpovede",
|
||||
|
@ -307,9 +307,8 @@
|
|||
"home.column_settings.basic": "Základné",
|
||||
"home.column_settings.show_reblogs": "Ukáž vyzdvihnuté",
|
||||
"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.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.link": "Pozri aktualizácie",
|
||||
"home.pending_critical_update.title": "Je dostupná kritická bezpečnostná aktualizácia!",
|
||||
|
|
|
@ -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 {
|
||||
CIRCLE_FETCH_SUCCESS,
|
||||
|
@ -7,31 +7,107 @@ import {
|
|||
CIRCLE_CREATE_SUCCESS,
|
||||
CIRCLE_UPDATE_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';
|
||||
import {
|
||||
COMPOSE_WITH_CIRCLE_SUCCESS,
|
||||
} from '../actions/compose';
|
||||
|
||||
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 => {
|
||||
state = normalizeList(state, circle);
|
||||
state = normalizeCircle(state, circle);
|
||||
});
|
||||
|
||||
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) {
|
||||
switch(action.type) {
|
||||
case CIRCLE_FETCH_SUCCESS:
|
||||
case CIRCLE_CREATE_SUCCESS:
|
||||
case CIRCLE_UPDATE_SUCCESS:
|
||||
return normalizeList(state, action.circle);
|
||||
return normalizeCircle(state, action.circle);
|
||||
case CIRCLES_FETCH_SUCCESS:
|
||||
return normalizeLists(state, action.circles);
|
||||
return normalizeCircles(state, action.circles);
|
||||
case CIRCLE_DELETE_SUCCESS:
|
||||
case CIRCLE_FETCH_FAIL:
|
||||
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:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ import {
|
|||
import { REDRAFT } from '../actions/statuses';
|
||||
import { STORE_HYDRATE } from '../actions/store';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
import { me } from '../initial_state';
|
||||
import { enableLocalPrivacy, enableLoginPrivacy, me } from '../initial_state';
|
||||
import { unescapeHTML } from '../utils/html';
|
||||
import { uuid } from '../uuid';
|
||||
|
||||
|
@ -138,9 +138,13 @@ function clearAll(state) {
|
|||
if (state.get('stay_privacy') && !state.get('in_reply_to')) {
|
||||
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')) {
|
||||
map.set('posted_on_this_session', true);
|
||||
}
|
||||
map.set('limited_scope', null);
|
||||
map.set('id', null);
|
||||
map.set('in_reply_to', null);
|
||||
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('text', statusToTextMentions(state, action.status));
|
||||
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('focusDate', new Date());
|
||||
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('in_reply_to', action.status.get('in_reply_to_id'));
|
||||
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('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
|
@ -574,7 +580,12 @@ export default function compose(state = initialState, action) {
|
|||
map.set('id', action.status.get('id'));
|
||||
map.set('text', action.text);
|
||||
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('focusDate', new Date());
|
||||
map.set('caretPosition', null);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
33
app/javascript/mastodon/reducers/dropdown_menu.ts
Normal file
33
app/javascript/mastodon/reducers/dropdown_menu.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
|
@ -24,7 +24,7 @@ import contexts from './contexts';
|
|||
import conversations from './conversations';
|
||||
import custom_emojis from './custom_emojis';
|
||||
import domain_lists from './domain_lists';
|
||||
import dropdown_menu from './dropdown_menu';
|
||||
import { dropdownMenuReducer } from './dropdown_menu';
|
||||
import filters from './filters';
|
||||
import followed_tags from './followed_tags';
|
||||
import height_cache from './height_cache';
|
||||
|
@ -56,7 +56,7 @@ import user_lists from './user_lists';
|
|||
|
||||
const reducers = {
|
||||
announcements,
|
||||
dropdown_menu,
|
||||
dropdownMenu: dropdownMenuReducer,
|
||||
timelines,
|
||||
meta,
|
||||
alerts,
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
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 type { ModalType } from '../actions/modal';
|
||||
import { openModal, closeModal } from '../actions/modal';
|
||||
import { TIMELINE_DELETE } from '../actions/timelines';
|
||||
|
||||
type ModalProps = Record<string, unknown>;
|
||||
export type ModalProps = Record<string, unknown>;
|
||||
interface Modal {
|
||||
modalType: ModalType;
|
||||
modalProps: ModalProps;
|
||||
|
@ -62,33 +62,22 @@ const pushModal = (
|
|||
});
|
||||
};
|
||||
|
||||
export function modalReducer(
|
||||
state: State = initialState,
|
||||
action: PayloadAction<{
|
||||
modalType: ModalType;
|
||||
ignoreFocus: boolean;
|
||||
modalProps: Record<string, unknown>;
|
||||
}>,
|
||||
) {
|
||||
switch (action.type) {
|
||||
case openModal.type:
|
||||
return pushModal(
|
||||
state,
|
||||
action.payload.modalType,
|
||||
action.payload.modalProps,
|
||||
);
|
||||
case closeModal.type:
|
||||
return popModal(state, action.payload);
|
||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||
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;
|
||||
}
|
||||
}
|
||||
export const modalReducer: Reducer<State> = (state = initialState, action) => {
|
||||
if (openModal.match(action))
|
||||
return pushModal(
|
||||
state,
|
||||
action.payload.modalType,
|
||||
action.payload.modalProps,
|
||||
);
|
||||
else if (closeModal.match(action)) return popModal(state, action.payload);
|
||||
// TODO: type those actions
|
||||
else if (action.type === COMPOSE_UPLOAD_CHANGE_SUCCESS)
|
||||
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
|
||||
else if (action.type === TIMELINE_DELETE)
|
||||
return state.update('stack', (stack) =>
|
||||
stack.filterNot(
|
||||
(modal) => modal.get('modalProps').statusId === action.id,
|
||||
),
|
||||
);
|
||||
else return state;
|
||||
};
|
||||
|
|
|
@ -64,6 +64,12 @@ import {
|
|||
EMOJI_REACTIONS_EXPAND_SUCCESS,
|
||||
EMOJI_REACTIONS_EXPAND_FAIL,
|
||||
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';
|
||||
import {
|
||||
MUTES_FETCH_REQUEST,
|
||||
|
@ -92,6 +98,7 @@ const initialState = ImmutableMap({
|
|||
favourited_by: initialListState,
|
||||
emoji_reactioned_by: initialListState,
|
||||
referred_by: initialListState,
|
||||
mentioned_users: initialListState,
|
||||
follow_requests: initialListState,
|
||||
blocks: 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);
|
||||
case STATUS_REFERENCES_FETCH_SUCCESS:
|
||||
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:
|
||||
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
|
||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||
|
|
|
@ -135,3 +135,7 @@ export const getStatusList = createSelector([
|
|||
export const getBookmarkCategoryStatusList = createSelector([
|
||||
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
|
||||
], (items) => items ? items.toList() : ImmutableList());
|
||||
|
||||
export const getCircleStatusList = createSelector([
|
||||
(state, circleId) => state.getIn(['circles', circleId, 'statuses', 'items']),
|
||||
], (items) => items ? items.toList() : ImmutableList());
|
||||
|
|
29
app/javascript/mastodon/utils/mentions.ts
Normal file
29
app/javascript/mastodon/utils/mentions.ts
Normal 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();
|
|
@ -284,6 +284,7 @@
|
|||
font-size: 11px;
|
||||
padding: 0 3px;
|
||||
line-height: 27px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
|
|
|
@ -72,12 +72,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
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
|
||||
@tags = []
|
||||
@mentions = []
|
||||
|
@ -120,7 +114,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
end
|
||||
|
||||
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 = {
|
||||
uri: @status_parser.uri,
|
||||
|
@ -136,7 +130,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
sensitive: @account.sensitized? || @status_parser.sensitive || false,
|
||||
visibility: @status_parser.visibility,
|
||||
limited_scope: @status_parser.limited_scope,
|
||||
searchability: searchability,
|
||||
searchability: @status_parser.searchability,
|
||||
thread: replied_to_status,
|
||||
conversation: conversation_from_uri(@object['conversation']),
|
||||
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!
|
||||
GroupReblogService.new.call(@status)
|
||||
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
|
||||
|
|
|
@ -10,6 +10,7 @@ class ActivityPub::Parser::StatusParser
|
|||
@json = json
|
||||
@object = magic_values[:object] || json['object'] || json
|
||||
@magic_values = magic_values
|
||||
@account = magic_values[:account]
|
||||
end
|
||||
|
||||
def uri
|
||||
|
@ -86,6 +87,14 @@ class ActivityPub::Parser::StatusParser
|
|||
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
|
||||
case @object['limitedScope']
|
||||
when 'Mutual'
|
||||
|
@ -98,6 +107,10 @@ class ActivityPub::Parser::StatusParser
|
|||
end
|
||||
|
||||
def language
|
||||
@language ||= original_language || (misskey_software? ? 'ja' : nil)
|
||||
end
|
||||
|
||||
def original_language
|
||||
if content_language_map?
|
||||
@object['contentMap'].keys.first
|
||||
elsif name_language_map?
|
||||
|
@ -117,6 +130,12 @@ class ActivityPub::Parser::StatusParser
|
|||
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||
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?
|
||||
@object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
|
||||
end
|
||||
|
@ -128,4 +147,61 @@ class ActivityPub::Parser::StatusParser
|
|||
def name_language_map?
|
||||
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
|
||||
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
|
||||
|
|
|
@ -157,9 +157,10 @@ class StatusReachFinder
|
|||
end
|
||||
|
||||
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)
|
||||
|
||||
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_info + from_domain_block).uniq
|
||||
end
|
||||
|
|
|
@ -397,6 +397,7 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def public_settings
|
||||
# Please update `app/javascript/mastodon/api_types/accounts.ts` when making changes to the attributes
|
||||
config = {
|
||||
'noindex' => noindex?,
|
||||
'noai' => noai?,
|
||||
|
|
|
@ -20,10 +20,12 @@ class Circle < ApplicationRecord
|
|||
|
||||
has_many :circle_accounts, inverse_of: :circle, dependent: :destroy
|
||||
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_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
|
||||
|
|
26
app/models/circle_status.rb
Normal file
26
app/models/circle_status.rb
Normal 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
|
|
@ -47,6 +47,7 @@ class Form::AdminSettings
|
|||
streaming_other_servers_emoji_reaction
|
||||
enable_emoji_reaction
|
||||
check_lts_version_only
|
||||
enable_public_unlisted_visibility
|
||||
).freeze
|
||||
|
||||
INTEGER_KEYS = %i(
|
||||
|
@ -74,6 +75,7 @@ class Form::AdminSettings
|
|||
streaming_other_servers_emoji_reaction
|
||||
enable_emoji_reaction
|
||||
check_lts_version_only
|
||||
enable_public_unlisted_visibility
|
||||
).freeze
|
||||
|
||||
UPLOAD_KEYS = %i(
|
||||
|
|
|
@ -13,6 +13,8 @@
|
|||
#
|
||||
|
||||
class Mention < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
belongs_to :account, inverse_of: :mentions
|
||||
belongs_to :status
|
||||
|
||||
|
|
|
@ -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 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
|
||||
|
||||
|
@ -106,6 +106,7 @@ class Status < ApplicationRecord
|
|||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
has_one :trend, class_name: 'StatusTrend', inverse_of: :status
|
||||
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 :text, presence: true, unless: -> { with_media? || reblog? }
|
||||
|
@ -450,11 +451,13 @@ class Status < ApplicationRecord
|
|||
|
||||
class << self
|
||||
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
|
||||
|
||||
def selectable_reblog_visibilities
|
||||
%w(unset) + visibilities.keys - %w(direct limited)
|
||||
%w(unset) + selectable_visibilities
|
||||
end
|
||||
|
||||
def selectable_searchabilities
|
||||
|
|
|
@ -24,6 +24,10 @@ class StatusPolicy < ApplicationPolicy
|
|||
end
|
||||
end
|
||||
|
||||
def show_mentioned_users?
|
||||
owned?
|
||||
end
|
||||
|
||||
def reblog?
|
||||
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
|
||||
end
|
||||
|
|
|
@ -37,6 +37,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
|||
status_page_url: Setting.status_page_url,
|
||||
sso_redirect: sso_redirect,
|
||||
dtl_tag: DTL_ENABLED ? DTL_TAG : nil,
|
||||
enable_local_privacy: Setting.enable_public_unlisted_visibility,
|
||||
}
|
||||
|
||||
if object.current_account
|
||||
|
|
|
@ -4,6 +4,8 @@ class REST::AccountSerializer < ActiveModel::Serializer
|
|||
include RoutingHelper
|
||||
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,
|
||||
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :subscribable,
|
||||
:followers_count, :following_count, :statuses_count, :last_status_at, :other_settings, :noindex
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
class REST::CustomEmojiSerializer < REST::CustomEmojiSlimSerializer
|
||||
include RoutingHelper
|
||||
|
||||
# Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes
|
||||
|
||||
attribute :aliases, if: :aliases?
|
||||
|
||||
def aliases?
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
class REST::CustomEmojiSlimSerializer < ActiveModel::Serializer
|
||||
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
|
||||
|
||||
attribute :category, if: :category_loaded?
|
||||
|
|
|
@ -108,7 +108,6 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
# for third party apps
|
||||
def fedibird_capabilities
|
||||
capabilities = [
|
||||
:kmyblue_visibility_public_unlisted,
|
||||
:enable_wide_emoji,
|
||||
:enable_wide_emoji_reaction,
|
||||
:kmyblue_searchability,
|
||||
|
@ -126,6 +125,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
capabilities << :emoji_reaction if Setting.enable_emoji_reaction
|
||||
capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility
|
||||
|
||||
capabilities
|
||||
end
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
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,
|
||||
:blocking, :blocked_by, :muting, :muting_notifications,
|
||||
:requested, :requested_by, :domain_blocking, :endorsed, :note
|
||||
|
|
|
@ -117,7 +117,6 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
|||
# for third party apps
|
||||
def fedibird_capabilities
|
||||
capabilities = [
|
||||
:kmyblue_visibility_public_unlisted,
|
||||
:enable_wide_emoji,
|
||||
:enable_wide_emoji_reaction,
|
||||
:kmyblue_searchability,
|
||||
|
@ -135,6 +134,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
|||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
capabilities << :emoji_reaction if Setting.enable_emoji_reaction
|
||||
capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility
|
||||
|
||||
capabilities
|
||||
end
|
||||
|
|
|
@ -10,7 +10,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||
|
||||
@activity_json = activity_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
|
||||
@status = status
|
||||
@account = status.account
|
||||
|
|
|
@ -78,7 +78,7 @@ class PostStatusService < BaseService
|
|||
@visibility = :direct if @in_reply_to&.limited_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 = :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
|
||||
@searchability = searchability
|
||||
@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 = nil if scheduled_in_the_past?
|
||||
@reference_ids = (@options[:status_reference_ids] || []).map(&:to_i).filter(&:positive?)
|
||||
raise ArgumentError if !Setting.enable_public_unlisted_visibility && @visibility == :public_unlisted
|
||||
|
||||
load_circle
|
||||
overwrite_dtl_post
|
||||
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)
|
||||
safeguard_mentions!(@status)
|
||||
|
||||
@status.limited_scope = :personal if @status.limited_visibility? && !process_mentions_service.mentions?
|
||||
|
||||
UpdateStatusExpirationService.new.call(@status)
|
||||
|
||||
# The following transaction block is needed to wrap the UPDATEs to
|
||||
|
@ -192,9 +196,9 @@ class PostStatusService < BaseService
|
|||
ProcessReferencesService.call_service(@status, @reference_ids, [])
|
||||
LinkCrawlWorker.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
|
||||
GroupReblogService.new.call(@status)
|
||||
GroupReblogService.new.call(@status) unless @status.personal_limited?
|
||||
end
|
||||
|
||||
def validate_status!
|
||||
|
@ -219,7 +223,7 @@ class PostStatusService < BaseService
|
|||
end
|
||||
|
||||
def process_mentions_service
|
||||
ProcessMentionsService.new
|
||||
@process_mentions_service ||= ProcessMentionsService.new
|
||||
end
|
||||
|
||||
def process_hashtags_service
|
||||
|
|
|
@ -24,6 +24,10 @@ class ProcessMentionsService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def mentions?
|
||||
@current_mentions.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scan_text!
|
||||
|
@ -112,5 +116,7 @@ class ProcessMentionsService < BaseService
|
|||
@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)
|
||||
end
|
||||
|
||||
@circle.statuses << @status
|
||||
end
|
||||
end
|
||||
|
|
|
@ -167,7 +167,13 @@ class UpdateStatusService < BaseService
|
|||
|
||||
def update_metadata!
|
||||
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
|
||||
|
||||
def broadcast_updates!
|
||||
|
|
|
@ -40,6 +40,11 @@
|
|||
.fields-group
|
||||
= 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')
|
||||
|
||||
.fields-group
|
||||
|
|
|
@ -21,8 +21,9 @@
|
|||
.fields-group
|
||||
= ff.input :stay_privacy, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_stay_privacy')
|
||||
|
||||
.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')
|
||||
- if Setting.enable_public_unlisted_visibility
|
||||
.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
|
||||
= ff.input :'web.enable_login_privacy', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_enable_login_privacy'), hint: false
|
||||
|
|
|
@ -26,8 +26,9 @@
|
|||
%p.lead= t('privacy_extra.stop_deliver_hint_html')
|
||||
|
||||
= f.simple_fields_for :settings, current_user.settings do |ff|
|
||||
.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')
|
||||
- if Setting.enable_public_unlisted_visibility
|
||||
.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
|
||||
= 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')
|
||||
|
|
|
@ -156,7 +156,6 @@ fa:
|
|||
admin:read:reports: خواندن اطّلاعات حساس از همهٔ گزارشها و حسابهای گزارششده
|
||||
admin:write: تغییر تمام دادهها روی کارساز
|
||||
admin:write:accounts: انجام کنش مدیریتی روی حسابها
|
||||
admin:write:canonical_email_blocks: انجام کنشهای نظارتی روی همهٔ انسدادهای رایانامهٔ متعارف
|
||||
admin:write:domain_allows: انجام کنش مدیریتی روی اجازههای دامنه
|
||||
admin:write:domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنه
|
||||
admin:write:email_domain_blocks: انجام کنش مدیریتی روی انسدادهای دامنهٔ رایانامه
|
||||
|
|
|
@ -817,6 +817,7 @@ en:
|
|||
publish_statistics: Publish statistics
|
||||
title: Discovery
|
||||
trends: Trends
|
||||
visibilities: Visibilities
|
||||
domain_blocks:
|
||||
all: To everyone
|
||||
disabled: To no one
|
||||
|
|
|
@ -814,6 +814,7 @@ ja:
|
|||
publish_statistics: 統計情報を公開する
|
||||
title: 見つける
|
||||
trends: トレンド
|
||||
visibilities: 公開範囲
|
||||
domain_blocks:
|
||||
all: 誰にでも許可
|
||||
disabled: 誰にも許可しない
|
||||
|
|
|
@ -291,11 +291,7 @@ cy:
|
|||
reblog: Mae rhywun wedi hybu eich postiad
|
||||
report: Cyflwynwyd adroddiad newydd
|
||||
software_updates:
|
||||
all: Rhoi gwybod am bob ddiweddariad
|
||||
critical: Rhoi gwybod am ddiweddariadau critigol yn unig
|
||||
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
|
||||
rule:
|
||||
text: Rheol
|
||||
|
|
|
@ -96,6 +96,7 @@ en:
|
|||
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.
|
||||
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.
|
||||
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.
|
||||
|
@ -268,8 +269,8 @@ en:
|
|||
setting_noai: Set noai meta tags
|
||||
setting_public_post_to_unlisted: Convert public post to public unlisted if not using Web app
|
||||
setting_reduce_motion: Reduce motion in animations
|
||||
setting_reject_public_unlisted_subscription: Reject sending public unlisted posts to Misskey, Calckey
|
||||
setting_reject_unlisted_subscription: Reject sending 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 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_show_application: Disclose application used to send posts
|
||||
setting_show_emoji_reaction_on_timeline: Show all stamps on timeline
|
||||
|
@ -321,6 +322,7 @@ en:
|
|||
content_cache_retention_period: Content cache retention period
|
||||
custom_css: Custom CSS
|
||||
enable_emoji_reaction: Enable stamp function
|
||||
enable_public_unlisted_visibility: Enable public-unlisted visibility
|
||||
mascot: Custom mascot (legacy)
|
||||
media_cache_retention_period: Media cache retention period
|
||||
peers_api_enabled: Publish list of discovered servers in the API
|
||||
|
|
|
@ -81,7 +81,7 @@ ja:
|
|||
setting_link_preview: プレビュー生成を停止することは、センシティブなサイトへのリンクを頻繁に投稿する人にも有効かもしれません
|
||||
setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません
|
||||
setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります
|
||||
setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください
|
||||
setting_reject_unlisted_subscription: Misskeyやそのフォークは、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください
|
||||
setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります
|
||||
setting_single_ref_to_quote: 当サーバーがまだ対象投稿を取り込んでいない場合、引用が相手に正常に認識されない場合があります
|
||||
setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます
|
||||
|
@ -109,6 +109,7 @@ ja:
|
|||
closed_registrations_message: アカウント作成を停止している時に表示されます
|
||||
content_cache_retention_period: 指定した日数が経過した他のサーバーの投稿とブーストを削除します。削除された投稿は再取得できない場合があります。削除された投稿についたブックマークやお気に入り、ブーストも失われ、元に戻せません。
|
||||
custom_css: ウェブ版のMastodonでカスタムスタイルを適用できます。
|
||||
enable_public_unlisted_visibility: 有効にするとあなたのコミュニティは閉鎖的になるかもしれません。この設定はkmyblueの主要機能の1つであり、無効にする場合は概要などに記載することを強くおすすめします。
|
||||
mascot: 上級者向けWebインターフェースのイラストを上書きします。
|
||||
media_cache_retention_period: 正の値に設定されている場合、ダウンロードされたメディアファイルは指定された日数の後に削除され、リクエストに応じて再ダウンロードされます。
|
||||
peers_api_enabled: このサーバーが Fediverse で遭遇したドメイン名のリストです。このサーバーが知っているだけで、特定のサーバーと連合しているかのデータは含まれません。これは一般的に Fediverse に関する統計情報を収集するサービスによって使用されます。
|
||||
|
@ -283,8 +284,8 @@ ja:
|
|||
setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する
|
||||
setting_public_post_to_unlisted: サードパーティから公開範囲「公開」で投稿した場合、「ローカル公開」に変更する
|
||||
setting_reduce_motion: アニメーションの動きを減らす
|
||||
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する
|
||||
setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する
|
||||
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
|
||||
setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」かつ検索許可「誰でも以外」の投稿を「フォロワーのみ」に変換して配送する
|
||||
setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨)
|
||||
setting_show_application: 送信したアプリを開示する
|
||||
setting_show_emoji_reaction_on_timeline: タイムライン上に他の人のつけたスタンプを表示する
|
||||
|
@ -336,6 +337,7 @@ ja:
|
|||
content_cache_retention_period: コンテンツキャッシュの保持期間
|
||||
custom_css: カスタムCSS
|
||||
enable_emoji_reaction: スタンプ機能を有効にする
|
||||
enable_public_unlisted_visibility: 公開範囲「ローカル公開」を有効にする
|
||||
mascot: カスタムマスコット(レガシー)
|
||||
media_cache_retention_period: メディアキャッシュの保持期間
|
||||
peers_api_enabled: 発見したサーバーのリストをAPIで公開する
|
||||
|
|
|
@ -18,7 +18,7 @@ Rails.application.routes.draw do
|
|||
/lists/(*any)
|
||||
/antennasw/(*any)
|
||||
/antennast/(*any)
|
||||
/circles
|
||||
/circles/(*any)
|
||||
/notifications
|
||||
/favourites
|
||||
/emoji_reactions
|
||||
|
|
|
@ -12,6 +12,7 @@ namespace :api, format: false do
|
|||
resources :favourited_by, controller: :favourited_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 :mentioned_by, controller: :mentioned_accounts, only: :index
|
||||
resources :bookmark_categories, only: :index
|
||||
resource :reblog, only: :create
|
||||
post :unreblog, to: 'reblogs#destroy'
|
||||
|
@ -226,6 +227,7 @@ namespace :api, format: false do
|
|||
|
||||
resources :circles, only: [:index, :create, :show, :update, :destroy] do
|
||||
resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts'
|
||||
resource :statuses, only: [:show], controller: 'circles/statuses'
|
||||
end
|
||||
|
||||
resources :bookmark_categories, only: [:index, :create, :show, :update, :destroy] do
|
||||
|
|
|
@ -42,6 +42,7 @@ defaults: &defaults
|
|||
streaming_other_servers_emoji_reaction: false
|
||||
enable_emoji_reaction: true
|
||||
check_lts_version_only: true
|
||||
enable_public_unlisted_visibility: true
|
||||
|
||||
development:
|
||||
<<: *defaults
|
||||
|
|
22
db/migrate/20230923103430_create_circle_statuses.rb
Normal file
22
db/migrate/20230923103430_create_circle_statuses.rb
Normal 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
|
14
db/schema.rb
14
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# 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
|
||||
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"
|
||||
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|
|
||||
t.bigint "account_id", 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", "circles", 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 "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
|
||||
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
|
||||
|
|
|
@ -5,15 +5,15 @@ module Mastodon
|
|||
module_function
|
||||
|
||||
def kmyblue_major
|
||||
5
|
||||
6
|
||||
end
|
||||
|
||||
def kmyblue_minor
|
||||
2
|
||||
0
|
||||
end
|
||||
|
||||
def kmyblue_flag
|
||||
'LTS'
|
||||
nil # 'LTS'
|
||||
end
|
||||
|
||||
def major
|
||||
|
|
43
spec/controllers/api/v1/circles/statuses_controller_spec.rb
Normal file
43
spec/controllers/api/v1/circles/statuses_controller_spec.rb
Normal 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
|
|
@ -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
|
7
spec/fabricators/circle_status_fabricator.rb
Normal file
7
spec/fabricators/circle_status_fabricator.rb
Normal 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
|
|
@ -1088,6 +1088,86 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
expect(poll.votes.first).to be_nil
|
||||
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
|
||||
|
||||
context 'with an encrypted message' do
|
||||
|
|
|
@ -9,8 +9,9 @@ describe StatusReachFinder do
|
|||
|
||||
let(:parent_status) { nil }
|
||||
let(:visibility) { :public }
|
||||
let(:searchability) { :public }
|
||||
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
|
||||
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
|
||||
|
||||
context 'when misskey' do
|
||||
context 'when misskey with private searchability' do
|
||||
let(:sender_software) { 'misskey' }
|
||||
let(:searchability) { :private }
|
||||
|
||||
it 'send status without setting' do
|
||||
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'
|
||||
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
|
||||
|
||||
context 'when it contains mentions of remote accounts' do
|
||||
|
|
|
@ -188,6 +188,17 @@ RSpec.describe PostStatusService, type: :service do
|
|||
expect(status.mentioned_accounts.first.id).to eq mutual_account.id
|
||||
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
|
||||
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
|
||||
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
|
||||
account = Fabricate(:account)
|
||||
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)
|
||||
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
|
||||
allow(LinkCrawlWorker).to receive(:perform_async)
|
||||
account = Fabricate(:account)
|
||||
|
|
|
@ -103,4 +103,27 @@ RSpec.describe ProcessMentionsService, type: :service do
|
|||
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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue