diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index be750a5e41..fa7a0c5353 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1 @@ -patreon: mastodon -open_collective: mastodon -custom: https://sponsor.joinmastodon.org +custom: https://fantia.jp/fanclubs/484677 diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml new file mode 100644 index 0000000000..10e7e53458 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml deleted file mode 100644 index 20e27d103c..0000000000 --- a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml new file mode 100644 index 0000000000..10fb4bb23b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml deleted file mode 100644 index 49d5f57209..0000000000 --- a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/3.feature_request.yml b/.github/ISSUE_TEMPLATE/3.feature_request.yml deleted file mode 100644 index 2cabcf61e0..0000000000 --- a/.github/ISSUE_TEMPLATE/3.feature_request.yml +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/3.spec_change_request.yml b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml new file mode 100644 index 0000000000..e71befe859 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3.spec_change_request.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f5d3196528..0086358db1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index f73bdcf786..4d397e3bdf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 44f20cdec6..e7e4348960 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/README.md b/README.md index 5620e7069c..887231ea41 100644 --- a/README.md +++ b/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 ### ローカル公開 diff --git a/app/controllers/api/v1/circles/statuses_controller.rb b/app/controllers/api/v1/circles/statuses_controller.rb new file mode 100644 index 0000000000..705731936b --- /dev/null +++ b/app/controllers/api/v1/circles/statuses_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb new file mode 100644 index 0000000000..4d905ef1a6 --- /dev/null +++ b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index 6a52e541c9..a497b27d5d 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -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, + }; +} + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1f682d1321..efe4c56406 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -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, diff --git a/app/javascript/mastodon/actions/dropdown_menu.js b/app/javascript/mastodon/actions/dropdown_menu.js deleted file mode 100644 index 023151d4bf..0000000000 --- a/app/javascript/mastodon/actions/dropdown_menu.js +++ /dev/null @@ -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 }; -} diff --git a/app/javascript/mastodon/actions/dropdown_menu.ts b/app/javascript/mastodon/actions/dropdown_menu.ts new file mode 100644 index 0000000000..3694df1ae0 --- /dev/null +++ b/app/javascript/mastodon/actions/dropdown_menu.ts @@ -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', +); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index b361809309..640c5c3128 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -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, + }; +} diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts index af34f5d6af..ab03e46765 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/actions/modal.ts @@ -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('MODAL_OPEN'); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts new file mode 100644 index 0000000000..e216fb4dd5 --- /dev/null +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -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; +} diff --git a/app/javascript/mastodon/api_types/custom_emoji.ts b/app/javascript/mastodon/api_types/custom_emoji.ts new file mode 100644 index 0000000000..45439f0d5a --- /dev/null +++ b/app/javascript/mastodon/api_types/custom_emoji.ts @@ -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[]; +} diff --git a/app/javascript/mastodon/api_types/relationships.ts b/app/javascript/mastodon/api_types/relationships.ts new file mode 100644 index 0000000000..9f26a0ce9b --- /dev/null +++ b/app/javascript/mastodon/api_types/relationships.ts @@ -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; +} diff --git a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js index a0896d985e..726fee9076 100644 --- a/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/components/edited_timestamp/containers/dropdown_menu_container.js @@ -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 })); }, }); diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list.jsx index 3165f4844d..30cc35ab69 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list.jsx @@ -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, }; }; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 2263fc542d..1a65aee51f 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -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) }, }; diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 02a42a92dd..a6f5e87037 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -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); } diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index 6cf180cd53..bc9124c041 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -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 })); }, }); diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 03f32a7e88..ccd7920428 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -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 (
@@ -182,6 +191,20 @@ class About extends PureComponent { ))} +
+

+ {!isLoading && ( +
    +
  1. + {intl.formatMessage(messages.emojiReaction)}: {intl.formatMessage(isEmojiReaction ? messages.enabled : messages.disabled)} +
  2. +
  3. + {intl.formatMessage(messages.publicUnlistedVisibility)}: {intl.formatMessage(isPublicUnlistedVisibility ? messages.enabled : messages.disabled)} +
  4. +
+ )} +
+
{domainBlocks.get('isLoading') ? ( <> diff --git a/app/javascript/mastodon/features/circle_statuses/index.jsx b/app/javascript/mastodon/features/circle_statuses/index.jsx new file mode 100644 index 0000000000..2896455ab5 --- /dev/null +++ b/app/javascript/mastodon/features/circle_statuses/index.jsx @@ -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 ( + +
+ +
+
+ ); + } else if (circle === false) { + return ( + + ); + } + + const emptyMessage = ; + + return ( + + +
+ + + +
+
+ + + + + {intl.formatMessage(messages.heading)} + + +
+ ); + } + +} + +export default connect(mapStateToProps)(injectIntl(CircleStatuses)); diff --git a/app/javascript/mastodon/features/circles/index.jsx b/app/javascript/mastodon/features/circles/index.jsx index 1b83876827..1cd3ae417f 100644 --- a/app/javascript/mastodon/features/circles/index.jsx +++ b/app/javascript/mastodon/features/circles/index.jsx @@ -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 => - (
- - -
) + , )} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 9782aa17bc..ef98aae479 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -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) => { diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index b01c0ede8b..b8acc4b4f2 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -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'); } diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx index 46b5d7fada..166d022b88 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.jsx +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.jsx @@ -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 { diff --git a/app/javascript/mastodon/features/compose/containers/circle_select_container.js b/app/javascript/mastodon/features/compose/containers/circle_select_container.js index c3a81140bd..141288c803 100644 --- a/app/javascript/mastodon/features/compose/containers/circle_select_container.js +++ b/app/javascript/mastodon/features/compose/containers/circle_select_container.js @@ -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']), }); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.jsx b/app/javascript/mastodon/features/compose/containers/warning_container.jsx index cfa8e8ab7d..bc74d1209b 100644 --- a/app/javascript/mastodon/features/compose/containers/warning_container.jsx +++ b/app/javascript/mastodon/features/compose/containers/warning_container.jsx @@ -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 }} />} />; } @@ -40,6 +42,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning return } />; } + if (mentionWarning) { + return } />; + } + if (limitedPostWarning) { return } />; } @@ -52,6 +58,7 @@ WarningWrapper.propTypes = { hashtagWarning: PropTypes.bool, directMessageWarning: PropTypes.bool, searchabilityWarning: PropTypes.bool, + mentionWarning: PropTypes.bool, limitedPostWarning: PropTypes.bool, }; diff --git a/app/javascript/mastodon/features/mentioned_users/index.jsx b/app/javascript/mastodon/features/mentioned_users/index.jsx new file mode 100644 index 0000000000..f32e38820e --- /dev/null +++ b/app/javascript/mastodon/features/mentioned_users/index.jsx @@ -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 ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + {accountIds.map(id => + , + )} + + + + + + + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(MentionedUsers)); diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx index a9610db75c..81b58296d5 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.jsx +++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx @@ -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) }, }; diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 56086075fc..2e5df723de 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -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 }); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index 63772a83d8..e9568bf9d0 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -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) }, }; diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx index fed1ef69df..f1a026b2b3 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx @@ -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) }, }; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index af549d21ed..280330f530 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -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, diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index f23fdc66fc..ee51220df6 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -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 { + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} @@ -259,6 +262,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 81d83ec818..2857fcec38 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 902158bc77..9158271429 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -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'); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 6cbb18c9b8..e553549508 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index efaadec145..9977e34b8f 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -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", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index da12eaa1b5..211787b1f2 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -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!", diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js index 805d7f186a..cadbb000e0 100644 --- a/app/javascript/mastodon/reducers/circles.js +++ b/app/javascript/mastodon/reducers/circles.js @@ -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; } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 612ee01d9b..dc34e7f1af 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -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); diff --git a/app/javascript/mastodon/reducers/dropdown_menu.js b/app/javascript/mastodon/reducers/dropdown_menu.js deleted file mode 100644 index 6f92f1bbe8..0000000000 --- a/app/javascript/mastodon/reducers/dropdown_menu.js +++ /dev/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; - } -} diff --git a/app/javascript/mastodon/reducers/dropdown_menu.ts b/app/javascript/mastodon/reducers/dropdown_menu.ts new file mode 100644 index 0000000000..59e19bb16d --- /dev/null +++ b/app/javascript/mastodon/reducers/dropdown_menu.ts @@ -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; + } + }); +}); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 05c24b7dfc..0731f42bbf 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -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, diff --git a/app/javascript/mastodon/reducers/modal.ts b/app/javascript/mastodon/reducers/modal.ts index dab1e8301c..73a2afb916 100644 --- a/app/javascript/mastodon/reducers/modal.ts +++ b/app/javascript/mastodon/reducers/modal.ts @@ -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; +export type ModalProps = Record; 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; - }>, -) { - 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 = 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; +}; diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 2cb41cd03d..ecf08dfbc3 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -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: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 6d5adbeb48..f6398e9324 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -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()); diff --git a/app/javascript/mastodon/utils/mentions.ts b/app/javascript/mastodon/utils/mentions.ts new file mode 100644 index 0000000000..0fbf28a3cf --- /dev/null +++ b/app/javascript/mastodon/utils/mentions.ts @@ -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(); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d6ee4f18f9..ca3a27defa 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -284,6 +284,7 @@ font-size: 11px; padding: 0 3px; line-height: 27px; + white-space: nowrap; &:hover, &:active, diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 6b8a715330..1f81fcda6c 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -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 diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index 852578721c..e0a234e110 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -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 diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 8639a30a9f..93f52bc7ec 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index 6c80cc9f6e..82532521a8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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?, diff --git a/app/models/circle.rb b/app/models/circle.rb index cb58b97bce..7c11ab59ea 100644 --- a/app/models/circle.rb +++ b/app/models/circle.rb @@ -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 diff --git a/app/models/circle_status.rb b/app/models/circle_status.rb new file mode 100644 index 0000000000..b394a4f927 --- /dev/null +++ b/app/models/circle_status.rb @@ -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 diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 08b546561d..a2de73bd14 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -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( diff --git a/app/models/mention.rb b/app/models/mention.rb index 2348b2905c..5addfcc583 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -13,6 +13,8 @@ # class Mention < ApplicationRecord + include Paginable + belongs_to :account, inverse_of: :mentions belongs_to :status diff --git a/app/models/status.rb b/app/models/status.rb index 719ba3eeda..c16c1a7c4f 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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 diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 335abe9e92..301ec4fdc4 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -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 diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 8f67f7e754..c4e9927c74 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -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 diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 772b71fe87..e9aa04bb43 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -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 diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index efcdaa8e86..13aee101d6 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -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? diff --git a/app/serializers/rest/custom_emoji_slim_serializer.rb b/app/serializers/rest/custom_emoji_slim_serializer.rb index c5542fbd27..6b17c15a03 100644 --- a/app/serializers/rest/custom_emoji_slim_serializer.rb +++ b/app/serializers/rest/custom_emoji_slim_serializer.rb @@ -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? diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index c8b4740bff..9b52277bf0 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -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 diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index b533874012..4d7ed75935 100644 --- a/app/serializers/rest/relationship_serializer.rb +++ b/app/serializers/rest/relationship_serializer.rb @@ -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 diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index 9e801a7898..64951b3db5 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -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 diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index dd6c8bc8a1..da82af8dff 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -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 diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index f48555245e..bf2ba2cdbf 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -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 diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 493facbea7..5053cf4ce3 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -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 diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 6e63fa9742..0a5de6b907 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -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! diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml index 6ea9e4fb4b..ee7d72ad32 100644 --- a/app/views/admin/settings/discovery/show.html.haml +++ b/app/views/admin/settings/discovery/show.html.haml @@ -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 diff --git a/app/views/settings/preferences/reaching/show.html.haml b/app/views/settings/preferences/reaching/show.html.haml index cfeaeff68e..03077758aa 100644 --- a/app/views/settings/preferences/reaching/show.html.haml +++ b/app/views/settings/preferences/reaching/show.html.haml @@ -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 diff --git a/app/views/settings/privacy_extra/show.html.haml b/app/views/settings/privacy_extra/show.html.haml index c9ccd03013..350841b7d5 100644 --- a/app/views/settings/privacy_extra/show.html.haml +++ b/app/views/settings/privacy_extra/show.html.haml @@ -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') diff --git a/config/locales/doorkeeper.fa.yml b/config/locales/doorkeeper.fa.yml index 5663413a3d..5547d502df 100644 --- a/config/locales/doorkeeper.fa.yml +++ b/config/locales/doorkeeper.fa.yml @@ -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: انجام کنش مدیریتی روی انسدادهای دامنهٔ رایانامه diff --git a/config/locales/en.yml b/config/locales/en.yml index ecb59c9463..d56dd1d3fb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -817,6 +817,7 @@ en: publish_statistics: Publish statistics title: Discovery trends: Trends + visibilities: Visibilities domain_blocks: all: To everyone disabled: To no one diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 85cb345e21..2447395e88 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -814,6 +814,7 @@ ja: publish_statistics: 統計情報を公開する title: 見つける trends: トレンド + visibilities: 公開範囲 domain_blocks: all: 誰にでも許可 disabled: 誰にも許可しない diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml index ebd590b8a7..b1bdc13be1 100644 --- a/config/locales/simple_form.cy.yml +++ b/config/locales/simple_form.cy.yml @@ -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 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index aa17a42816..2012e1a7f7 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -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 diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index e255c43cd4..2a03f00fdb 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -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で公開する diff --git a/config/routes.rb b/config/routes.rb index 3db26c7f8c..ed5995b2f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,7 @@ Rails.application.routes.draw do /lists/(*any) /antennasw/(*any) /antennast/(*any) - /circles + /circles/(*any) /notifications /favourites /emoji_reactions diff --git a/config/routes/api.rb b/config/routes/api.rb index 9bf466ddab..005d8f6839 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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 diff --git a/config/settings.yml b/config/settings.yml index 69b3ed1ee3..c0b5f4109b 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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 diff --git a/db/migrate/20230923103430_create_circle_statuses.rb b/db/migrate/20230923103430_create_circle_statuses.rb new file mode 100644 index 0000000000..9c14bb808a --- /dev/null +++ b/db/migrate/20230923103430_create_circle_statuses.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index c5dcfc6a33..e17320cdaa 100644 --- a/db/schema.rb +++ b/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 diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index c651a9761d..bcb0741bd1 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -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 diff --git a/spec/controllers/api/v1/circles/statuses_controller_spec.rb b/spec/controllers/api/v1/circles/statuses_controller_spec.rb new file mode 100644 index 0000000000..2a323aa0f8 --- /dev/null +++ b/spec/controllers/api/v1/circles/statuses_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb new file mode 100644 index 0000000000..2299791344 --- /dev/null +++ b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb @@ -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 diff --git a/spec/fabricators/circle_status_fabricator.rb b/spec/fabricators/circle_status_fabricator.rb new file mode 100644 index 0000000000..649d4f4438 --- /dev/null +++ b/spec/fabricators/circle_status_fabricator.rb @@ -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 diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 9c4bba3e62..c199fcd038 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -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 diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb index 57946d3a70..4292f12bc6 100644 --- a/spec/lib/status_reach_finder_spec.rb +++ b/spec/lib/status_reach_finder_spec.rb @@ -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 diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index a2cf6fbfb0..5e2a54c264 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -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) diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 0db73c41fa..39bb355577 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -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 diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 9c53ebb2fd..288466bdeb 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -166,6 +166,39 @@ RSpec.describe UpdateStatusService, type: :service do end end + context 'when personal_limited mentions in text change' do + let!(:account) { Fabricate(:account) } + let!(:bob) { Fabricate(:account, username: 'bob') } + let!(:status) { PostStatusService.new.call(account, text: 'Hello', visibility: 'circle', circle_id: Fabricate(:circle, account: account).id) } + + before do + subject.call(status, status.account_id, text: 'Hello @bob') + end + + it 'changes mentions' do + expect(status.active_mentions.pluck(:account_id)).to eq [bob.id] + end + + it 'changes visibilities' do + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'circle' + end + end + + context 'when personal_limited in text change' do + let!(:account) { Fabricate(:account) } + let!(:status) { PostStatusService.new.call(account, text: 'Hello', visibility: 'circle', circle_id: Fabricate(:circle, account: account).id) } + + before do + subject.call(status, status.account_id, text: 'AAA') + end + + it 'not changing visibilities' do + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'personal' + end + end + context 'when hashtags in text change' do let!(:account) { Fabricate(:account) } let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') } diff --git a/yarn.lock b/yarn.lock index 34a7b4d0ab..7f72a6d0cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1298,16 +1298,11 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.5.1": +"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": version "4.8.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.1.tgz#8c4bb756cc2aa7eaf13cfa5e69c83afb3260c20c" integrity sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ== -"@eslint-community/regexpp@^4.6.1": - version "4.8.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005" - integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg== - "@eslint/eslintrc@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396" @@ -1323,10 +1318,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.49.0": - version "8.49.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" - integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== +"@eslint/js@8.50.0": + version "8.50.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.50.0.tgz#9e93b850f0f3fa35f5fa59adfd03adae8488e484" + integrity sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ== "@floating-ui/core@^1.3.1": version "1.3.1" @@ -1831,9 +1826,9 @@ integrity sha512-tOQQBVH8LsUpGXqDnk+kaOGVsgZ8maHAhEiw3Git3p88q+c0Slgu47HuDnL6sVxeCfz24zbq7dOjsVYDiTpDIA== "@reduxjs/toolkit@^1.9.5": - version "1.9.5" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4" - integrity sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ== + version "1.9.6" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.6.tgz#fc968b45fe5b17ff90932c4556960d9c1078365a" + integrity sha512-Gc4ikl90ORF4viIdAkY06JNUnODjKfGxZRwATM30EdHq8hLSVoSrwXne5dd739yenP5bJxAX7tLuOWK5RPGtrw== dependencies: immer "^9.0.21" redux "^4.2.1" @@ -2127,24 +2122,24 @@ "@babel/types" "^7.20.7" "@types/body-parser@*": - version "1.19.2" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" - integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + version "1.19.3" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd" + integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ== dependencies: "@types/connect" "*" "@types/node" "*" "@types/connect@*": - version "3.4.35" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" - integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + version "3.4.36" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab" + integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w== dependencies: "@types/node" "*" "@types/emoji-mart@^3.0.9": - version "3.0.9" - resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.9.tgz#2f7ef5d9ec194f28029c46c81a5fc1e5b0efa73c" - integrity sha512-qdBo/2Y8MXaJ/2spKjDZocuq79GpnOhkwMHnK2GnVFa8WYFgfA+ei6sil3aeWQPCreOKIx9ogPpR5+7MaOqYAA== + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.10.tgz#d2248c08758094377bd8f438cf13b1364b8b6649" + integrity sha512-WP5Vw1CLsTQpPT/Hj+shIMC5TB4pyoJourYQe01ceYtJVEopTwuXbCTE6f7aHOKj26E/Y+oZaPtKBtnG1S4d2Q== dependencies: "@types/react" "*" @@ -2172,9 +2167,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@^4.17.33": - version "4.17.35" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz#c95dd4424f0d32e525d23812aa8ab8e4d3906c4f" - integrity sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg== + version "4.17.37" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320" + integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg== dependencies: "@types/node" "*" "@types/qs" "*" @@ -2182,9 +2177,9 @@ "@types/send" "*" "@types/express@^4.17.17": - version "4.17.17" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" - integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + version "4.17.18" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" + integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33" @@ -2219,6 +2214,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/http-errors@*": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2" + integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg== + "@types/http-link-header@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/http-link-header/-/http-link-header-1.0.3.tgz#899adf1d8d2036074514f3dbd148fb901ceff920" @@ -2293,9 +2293,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash@^4.14.195": - version "4.14.198" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.198.tgz#4d27465257011aedc741a809f1269941fa2c5d4c" - integrity sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg== + version "4.14.199" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.199.tgz#c3edb5650149d847a277a8961a7ad360c474e9bf" + integrity sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg== "@types/mime@*": version "3.0.1" @@ -2318,9 +2318,9 @@ integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== "@types/node@*": - version "20.6.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.2.tgz#a065925409f59657022e9063275cd0b9bd7e1b12" - integrity sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw== + version "20.6.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.5.tgz#4c6a79adf59a8e8193ac87a0e522605b16587258" + integrity sha512-2qGq5LAOTh9izcc0+F+dToFigBWiK1phKPt7rNhOqJSr35y8rlIBjDwGtFSgAI6MGIhjwOVNSQZVdJsZJ2uR1w== "@types/node@14 || 16 || 17": version "17.0.45" @@ -2328,9 +2328,9 @@ integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== "@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz#9b0e3e8533fe5024ad32d6637eb9589988b6fdca" + integrity sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A== "@types/npmlog@^4.1.4": version "4.1.4" @@ -2348,9 +2348,9 @@ integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== "@types/pg@^8.6.6": - version "8.10.2" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.10.2.tgz#7814d1ca02c8071f4d0864c1b17c589b061dba43" - integrity sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw== + version "8.10.3" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.10.3.tgz#39b3acba4f313a65c8fbb4b241fcb21cc1ba4126" + integrity sha512-BACzsw64lCZesclRpZGu55tnqgFAYcrCBP92xLh1KLypZLCOsvJTSTgaoFVTy3lCys/aZTQzfeDxtjwrvdzL2g== dependencies: "@types/node" "*" pg-protocol "*" @@ -2362,9 +2362,9 @@ integrity sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g== "@types/prop-types@*", "@types/prop-types@^15.7.5": - version "15.7.6" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.6.tgz#bbf819813d6be21011b8f5801058498bec555572" - integrity sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg== + version "15.7.7" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.7.tgz#f9361f7b87fd5d8188b2c998db0a1f47e9fb391a" + integrity sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog== "@types/punycode@^2.1.0": version "2.1.0" @@ -2536,10 +2536,11 @@ "@types/node" "*" "@types/serve-static@*": - version "1.15.1" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d" - integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ== + version "1.15.2" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a" + integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw== dependencies: + "@types/http-errors" "*" "@types/mime" "*" "@types/node" "*" @@ -2612,14 +2613,14 @@ source-map "^0.6.0" "@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== + version "21.0.1" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.1.tgz#07773d7160494d56aa882d7531aac7319ea67c3b" + integrity sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ== "@types/yargs@^17.0.24", "@types/yargs@^17.0.8": - version "17.0.24" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" - integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + version "17.0.25" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.25.tgz#3edd102803c97356fb4c805b2bbaf7dfc9ab6abc" + integrity sha512-gy7iPgwnzNvxgAEi2bXOHWCVOG6f7xsprVJH4MjlAWeBmJ7vh/Y1kwMtUrs64ztf24zVIRCpr3n/z6gm9QIkgg== dependencies: "@types/yargs-parser" "*" @@ -5377,9 +5378,9 @@ eslint-import-resolver-node@^0.3.7: resolve "^1.22.4" eslint-import-resolver-typescript@^3.5.5: - version "3.6.0" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.0.tgz#36f93e1eb65a635e688e16cae4bead54552e3bbd" - integrity sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg== + version "3.6.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz#7b983680edd3f1c5bce1a5829ae0bc2d57fe9efa" + integrity sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg== dependencies: debug "^4.3.4" enhanced-resolve "^5.12.0" @@ -5437,9 +5438,9 @@ eslint-plugin-import@~2.28.0: tsconfig-paths "^3.14.2" eslint-plugin-jsdoc@^46.1.0: - version "46.8.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.8.1.tgz#cfc649c15d460903fe8e86eda582023bb97f283a" - integrity sha512-uTce7IBluPKXIQMWJkIwFsI1gv7sZRmLjctca2K5DIxPi8fSBj9f4iru42XmGwuiMyH2f3nfc60sFmnSGv4Z/A== + version "46.8.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.8.2.tgz#3e6b1c93e91e38fe01874d45da121b56393c54a5" + integrity sha512-5TSnD018f3tUJNne4s4gDWQflbsgOycIKEUBoCLn6XtBMgNHxQFmV8vVxUtiPxAQq8lrX85OaSG/2gnctxw9uQ== dependencies: "@es-joy/jsdoccomment" "~0.40.1" are-docs-informative "^0.0.2" @@ -5535,14 +5536,14 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== eslint@^8.41.0: - version "8.49.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" - integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== + version "8.50.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.50.0.tgz#2ae6015fee0240fcd3f83e1e25df0287f487d6b2" + integrity sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" "@eslint/eslintrc" "^2.1.2" - "@eslint/js" "8.49.0" + "@eslint/js" "8.50.0" "@humanwhocodes/config-array" "^0.11.11" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -6017,9 +6018,9 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== + version "3.2.9" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" + integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== follow-redirects@^1.0.0, follow-redirects@^1.15.0: version "1.15.2" @@ -6212,9 +6213,9 @@ get-symbol-description@^1.0.0: get-intrinsic "^1.1.1" get-tsconfig@^4.5.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.0.tgz#06ce112a1463e93196aa90320c35df5039147e34" - integrity sha512-pmjiZ7xtB8URYm74PlGJozDNyhvsVLUcpBa8DZBG3bWHwaHa9bPiRpiSfovw+fjhwONSCWKRyk+JQHEGZmMrzw== + version "4.7.2" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.2.tgz#0dcd6fb330391d46332f4c6c1bf89a6514c2ddce" + integrity sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A== dependencies: resolve-pkg-maps "^1.0.0" @@ -6246,12 +6247,12 @@ glob-parent@^6.0.2: is-glob "^4.0.3" glob@^10.2.5, glob@^10.2.6: - version "10.3.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.5.tgz#4c0e46b5bccd78ac42b06a7eaaeb9ee34062968e" - integrity sha512-bYUpUD7XDEHI4Q2O5a7PXGvyw4deKR70kHiDxzQbe925wbZknhOzUt2xBgTkYL6RBcVeXYuD9iNYeqoWbBZQnA== + version "10.3.9" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.9.tgz#181ae87640ecce9b2fc5b96e4e2d70b7c3629ab8" + integrity sha512-2tU/LKevAQvDVuVJ9pg9Yv9xcbSh+TqHuTaXTNbQwf+0kDl9Fm6bMovi4Nm5c8TVvfxo2LLcqCGtmO9KoJaGWg== dependencies: foreground-child "^3.1.0" - jackspeak "^2.0.3" + jackspeak "^2.3.5" minimatch "^9.0.1" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" @@ -6310,9 +6311,9 @@ globals@^11.1.0: integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== globals@^13.19.0: - version "13.21.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.21.0.tgz#163aae12f34ef502f5153cfbdd3600f36c63c571" - integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg== + version "13.22.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8" + integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw== dependencies: type-fest "^0.20.2" @@ -7365,10 +7366,10 @@ iterator.prototype@^1.1.0: has-tostringtag "^1.0.0" reflect.getprototypeof "^1.0.3" -jackspeak@^2.0.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.3.tgz#95e4cbcc03b3eb357bf6bcce14a903fb3d1151e1" - integrity sha512-R2bUw+kVZFS/h1AZqBKrSgDmdmjApzgY0AlCPumopFiAlbUxE2gf+SCuBzQ0cP5hHmUmFYF5yw55T97Th5Kstg== +jackspeak@^2.3.5: + version "2.3.5" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.5.tgz#443f237f9eeeb0d7c6ec34835ef5289bb4acb068" + integrity sha512-Ratx+B8WeXLAtRJn26hrhY8S1+Jz6pxPMrkrdkgb/NstTNiqMhX0/oFVu5wX+g5n6JlEu2LPsDJmY8nRP4+alw== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: @@ -11231,9 +11232,9 @@ spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.13" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz#7189a474c46f8d47c7b0da4b987bb45e908bd2d5" - integrity sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w== + version "3.0.15" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba" + integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ== spdy-transport@^3.0.0: version "3.0.0"