1
0
Fork 0
forked from gitea/nas

Merge branch 'kb_development' into kb-connect-lts-to-development

This commit is contained in:
KMY 2023-10-15 12:55:54 +09:00
commit b96a720e2c
310 changed files with 6354 additions and 1401 deletions

View file

@ -1,4 +0,0 @@
---
ignore:
# Sidekiq security issue, fixes in the latest Sidekiq 7 but we can not upgrade. Will be fixed in Sidekiq 6.5.10
- CVE-2023-26141

View file

@ -70,7 +70,7 @@ services:
hard: -1
libretranslate:
image: libretranslate/libretranslate:v1.3.11
image: libretranslate/libretranslate:v1.3.12
restart: unless-stopped
volumes:
- lt-data:/home/libretranslate/.local

View file

@ -9,7 +9,6 @@ module.exports = {
'plugin:import/recommended',
'plugin:promise/recommended',
'plugin:jsdoc/recommended',
'plugin:prettier/recommended',
],
env: {
@ -63,7 +62,9 @@ module.exports = {
'consistent-return': 'error',
'dot-notation': 'error',
eqeqeq: ['error', 'always', { 'null': 'ignore' }],
'indent': ['error', 2],
'jsx-quotes': ['error', 'prefer-single'],
'semi': ['error', 'always'],
'no-case-declarations': 'off',
'no-catch-shadow': 'error',
'no-console': [

4
.github/FUNDING.yml vendored
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -284,8 +284,8 @@ jobs:
ports:
- 6379:6379
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.13
search:
image: ${{ matrix.search-image }}
env:
discovery.type: single-node
xpack.security.enabled: false
@ -315,6 +315,11 @@ jobs:
- '3.0'
- '3.1'
- '.ruby-version'
search-image:
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13
include:
- ruby-version: '.ruby-version'
search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2
steps:
- uses: actions/checkout@v4

3
.gitignore vendored
View file

@ -31,9 +31,6 @@
# Ignore Vagrant files
.vagrant/
# Ignore Capistrano customizations
/config/deploy/*
# Ignore IDE files
.vscode/
.idea/

View file

@ -1,13 +1,13 @@
# This configuration was generated by
# `haml-lint --auto-gen-config`
# on 2023-07-20 09:47:50 -0400 using Haml-Lint version 0.48.0.
# on 2023-10-11 11:31:24 -0400 using Haml-Lint version 0.51.0.
# The point is for the user to remove these configuration records
# one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again.
linters:
# Offense count: 951
# Offense count: 946
LineLength:
enabled: false
@ -15,7 +15,7 @@ linters:
UnnecessaryStringOutput:
enabled: false
# Offense count: 57
# Offense count: 44
RuboCop:
enabled: false
@ -27,23 +27,13 @@ linters:
- 'app/views/admin/reports/show.html.haml'
- 'app/views/disputes/strikes/show.html.haml'
# Offense count: 32
# Offense count: 15
InstanceVariables:
exclude:
- 'app/views/admin/reports/_actions.html.haml'
- 'app/views/admin/roles/_form.html.haml'
- 'app/views/admin/webhooks/_form.html.haml'
- 'app/views/auth/registrations/_status.html.haml'
- 'app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml'
- 'app/views/authorize_interactions/_post_follow_actions.html.haml'
- 'app/views/invites/_form.html.haml'
- 'app/views/relationships/_account.html.haml'
- 'app/views/shared/_og.html.haml'
- 'app/views/application/_sidebar.html.haml'
# Offense count: 3
# Offense count: 2
IdNames:
exclude:
- 'app/views/authorize_interactions/error.html.haml'
- 'app/views/oauth/authorizations/error.html.haml'
- 'app/views/shared/_error_messages.html.haml'

2
.nvmrc
View file

@ -1 +1 @@
20.7
20.8

View file

@ -31,9 +31,6 @@
# Ignore Vagrant files
.vagrant/
# Ignore Capistrano customizations
/config/deploy/*
# Ignore IDE files
.vscode/
.idea/

View file

@ -28,6 +28,7 @@ AllCops:
- 'Vagrantfile'
- 'vendor/**/*'
- 'lib/json_ld/*' # Generated files
- 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab
- 'lib/templates/**/*'
# Reason: Prefer Hashes without extreme indentation
@ -76,12 +77,6 @@ Metrics/AbcSize:
- 'lib/mastodon/cli/*.rb'
- db/*migrate/**/*
# Reason:
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting
Metrics/BlockNesting:
Exclude:
- 'lib/mastodon/cli/*.rb'
# Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity
Metrics/CyclomaticComplexity:

View file

@ -13,32 +13,6 @@ Bundler/OrderedGems:
Exclude:
- 'Gemfile'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, IndentationWidth.
# SupportedStyles: with_first_argument, with_fixed_indentation
Layout/ArgumentAlignment:
Exclude:
- 'config/initializers/cors.rb'
- 'config/initializers/session_store.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
# SupportedHashRocketStyles: key, separator, table
# SupportedColonStyles: key, separator, table
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
Layout/HashAlignment:
Exclude:
- 'config/environments/production.rb'
- 'config/initializers/rack_attack.rb'
- 'config/routes.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment.
Layout/LeadingCommentSpace:
Exclude:
- 'config/application.rb'
- 'config/initializers/3_omniauth.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.
# URISchemes: http, https
@ -46,14 +20,6 @@ Layout/LineLength:
Exclude:
- 'app/models/account.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: require_no_space, require_space
Layout/SpaceInLambdaLiteral:
Exclude:
- 'config/environments/production.rb'
- 'config/initializers/content_security_policy.rb'
# Configuration parameters: AllowComments, AllowEmptyLambdas.
Lint/EmptyBlock:
Exclude:
@ -844,6 +810,5 @@ Style/TrailingCommaInHashLiteral:
Style/WordArray:
Exclude:
- 'app/helpers/languages_helper.rb'
- 'config/initializers/cors.rb'
- 'spec/controllers/settings/imports_controller_spec.rb'
- 'spec/models/form/import_spec.rb'

15
Capfile
View file

@ -1,15 +0,0 @@
# frozen_string_literal: true
require 'capistrano/setup'
require 'capistrano/deploy'
require 'capistrano/scm/git'
install_plugin Capistrano::SCM::Git
require 'capistrano/rbenv'
require 'capistrano/bundler'
require 'capistrano/yarn'
require 'capistrano/rails/assets'
require 'capistrano/rails/migrations'
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }

View file

@ -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.8-bookworm-slim"
FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby
FROM node:${NODE_VERSION} as build

View file

@ -172,12 +172,6 @@ group :development do
# Linter CLI for HAML files
gem 'haml_lint', require: false
# Deployment automation
gem 'capistrano', '~> 3.17'
gem 'capistrano-rails', '~> 1.6'
gem 'capistrano-rbenv', '~> 2.2'
gem 'capistrano-yarn', '~> 2.0'
# Validate missing i18n keys
gem 'i18n-tasks', '~> 1.0', require: false
end

View file

@ -84,9 +84,9 @@ GEM
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_serializers (0.10.13)
actionpack (>= 4.1, < 7.1)
activemodel (>= 4.1, < 7.1)
active_model_serializers (0.10.14)
actionpack (>= 4.1)
activemodel (>= 4.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.0.8)
@ -112,8 +112,6 @@ GEM
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
aes_key_wrap (1.1.0)
airbrussh (1.4.1)
sshkit (>= 1.6.1, != 1.7.0)
android_key_attestation (0.3.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@ -148,7 +146,7 @@ GEM
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.18)
bcrypt (3.1.19)
better_errors (2.10.1)
erubi (>= 1.0.0)
rack (>= 0.9.0)
@ -175,21 +173,6 @@ GEM
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capistrano (3.17.3)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (2.1.0)
capistrano (~> 3.1)
capistrano-rails (1.6.3)
capistrano (~> 3.1)
capistrano-bundler (>= 1.1, < 3)
capistrano-rbenv (2.2.0)
capistrano (~> 3.1)
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.39.2)
addressable
matrix
@ -227,7 +210,7 @@ GEM
database_cleaner-core (2.0.1)
date (3.3.3)
debug_inspector (1.1.0)
devise (4.9.2)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@ -324,7 +307,7 @@ GEM
ruby-progressbar (~> 1.4)
globalid (1.1.0)
activesupport (>= 5.0)
haml (6.1.2)
haml (6.2.0)
temple (>= 0.8.2)
thor
tilt
@ -333,8 +316,8 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.50.0)
haml (>= 4.0, < 6.2)
haml_lint (0.51.0)
haml (>= 4.0)
parallel (~> 1.10)
rainbow
rubocop (>= 1.0)
@ -429,12 +412,12 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lograge (0.13.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.21.3)
loofah (2.21.4)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@ -457,7 +440,7 @@ GEM
mime-types-data (3.2023.0808)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
minitest (5.20.0)
msgpack (1.7.1)
multi_json (1.15.0)
multipart-post (2.3.0)
@ -473,11 +456,8 @@ GEM
net-protocol
net-protocol (0.2.1)
timeout
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-smtp (0.3.3)
net-protocol
net-ssh (7.1.0)
nio4r (2.5.9)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
@ -513,7 +493,7 @@ GEM
orm_adapter (0.5.0)
ox (2.14.17)
parallel (1.23.0)
parser (3.2.2.3)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
parslet (2.0.0)
@ -574,7 +554,7 @@ GEM
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.1.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
@ -603,10 +583,10 @@ GEM
redis (>= 4)
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.8.1)
regexp_parser (2.8.2)
request_store (1.5.1)
rack (>= 1.4)
responders (3.1.0)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.6)
@ -642,12 +622,12 @@ GEM
sidekiq (>= 5, < 8)
rspec-support (3.12.1)
rspec_chunked (0.6)
rubocop (1.56.3)
rubocop (1.57.0)
base64 (~> 0.1.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.3)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
@ -688,12 +668,12 @@ 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)
semantic_range (3.0.0)
sidekiq (6.5.10)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
rack (~> 2.0)
redis (>= 4.5.0, < 5)
@ -728,9 +708,6 @@ GEM
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sshkit (1.21.5)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stoplight (3.0.2)
@ -749,7 +726,7 @@ GEM
climate_control (>= 0.0.3, < 1.0)
test-prof (1.2.3)
thor (1.2.2)
tilt (2.2.0)
tilt (2.3.0)
timeout (0.4.0)
tpm-key_attestation (0.12.0)
bindata (~> 2.4)
@ -775,7 +752,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
unicode-display_width (2.5.0)
uri (0.12.2)
validate_email (0.1.6)
activemodel (>= 3.0)
@ -806,7 +783,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)
@ -814,7 +791,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.11)
zeitwerk (2.6.12)
PLATFORMS
ruby
@ -831,10 +808,6 @@ DEPENDENCIES
brakeman (~> 6.0)
browser
bundler-audit (~> 0.9)
capistrano (~> 3.17)
capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0)
capybara (~> 3.39)
charlock_holmes (~> 0.7.7)
chewy (~> 7.3)

View file

@ -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は絵文字リアクションに対応しているフォークのつです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家Mastodonには搭載されていません。絵文字リアクションによってユーザーは「お気に入り」以上「返信」以下のコミュニケーションを気軽に行うことができ、Mastodonの利用体験が向上します。
各ユーザーが自分の投稿に絵文字リアクションをつけることを拒否できるほか、サーバー全体として絵文字リアクションを無効にする設定も可能です(この場合、他サーバーから来た絵文字リアクションはお気に入りとして保存されます)
### プライバシーへの配慮
- **ローカル公開** - ローカルタイムラインにのみ投稿を流し、他サーバーの連合タイムラインに流しません。他のサーバーには未収載として配信されます
- **検索許可** - 投稿ごとに検索を許可する範囲を細かく制御できます。これは本家Mastodonにはない特徴です
- **Misskeyへの投稿配送制限** - Misskeyへ未収載投稿を配送する時、「フォロワーのみ」に変換する設定がユーザー個別に可能です。Misskeyの自由な検索からkmyblue上の投稿を保護します
## kmyblueのブランチ
- **main** - 管理者が本家MastodonにPRするときに使うことがあります
@ -58,7 +76,9 @@ kmyblueは絵文字リアクションに対応しているフォークの
## 本家Mastodonからの追加機能
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。
kmyblueは、本家Mastodonにいくつかの改造を加えています。以下に示します。ただし以下はあくまで一例です。ほぼ完全な一覧は、以下の記事を参照してください。
https://note.com/kmycode/n/n5fd5e823ed40
### ローカル公開

View file

@ -5,15 +5,7 @@ class AboutController < ApplicationController
skip_before_action :require_functional!
before_action :set_instance_presenter
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View file

@ -89,17 +89,17 @@ module Admin
def update_params
params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag,
:reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
:reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
end
def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag,
:reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
:reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
end
def form_domain_block_batch_params
params.require(:form_domain_block_batch).permit(domain_blocks_attributes: [:enabled, :domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media,
:reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous])
:reject_send_sensitive, :reject_hashtag, :reject_straight_follow, :reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous])
end
def action_from_button

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
module Admin
class FriendServersController < BaseController
before_action :set_friend, except: [:index, :new, :create]
before_action :warn_signatures_not_enabled!, only: [:new, :edit, :create, :follow, :unfollow, :accept, :reject]
def index
authorize :friend_server, :update?
@friends = FriendDomain.all
end
def new
authorize :friend_server, :update?
@friend = FriendDomain.new
end
def edit
authorize :friend_server, :update?
end
def create
authorize :friend_server, :update?
@friend = FriendDomain.new(resource_params)
if @friend.save
@friend.follow!
redirect_to admin_friend_servers_path
else
render action: :new
end
end
def update
authorize :friend_server, :update?
if @friend.update(update_resource_params)
redirect_to admin_friend_servers_path
else
render action: :edit
end
end
def destroy
authorize :friend_server, :update?
@friend.destroy
redirect_to admin_friend_servers_path
end
def follow
authorize :friend_server, :update?
@friend.follow!
render action: :edit
end
def unfollow
authorize :friend_server, :update?
@friend.unfollow!
render action: :edit
end
def accept
authorize :friend_server, :update?
@friend.accept!
render action: :edit
end
def reject
authorize :friend_server, :update?
@friend.reject!
render action: :edit
end
private
def set_friend
@friend = FriendDomain.find(params[:id])
end
def resource_params
params.require(:friend_domain).permit(:domain, :inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts)
end
def update_resource_params
params.require(:friend_domain).permit(:inbox_url, :available, :pseudo_relay, :delivery_local, :unlocked, :allow_all_posts)
end
def warn_signatures_not_enabled!
flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
end
end
end

View file

@ -70,7 +70,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
def domain_block_params
params.permit(:severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_reports, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow,
:reject_new_follow, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
:reject_new_follow, :reject_friend, :detect_invalid_subscription, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
end
def insert_pagination_headers
@ -103,6 +103,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
def resource_params
params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :reject_reply_exclude_followers, :reject_send_not_public_searchability, :reject_send_public_unlisted, :reject_send_dissubscribable, :reject_send_media, :reject_send_sensitive, :reject_hashtag, :reject_straight_follow,
:reject_new_follow, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
:reject_new_follow, :reject_friend, :detect_invalid_subscription, :reject_reports, :private_comment, :public_comment, :obfuscate, :hidden, :hidden_anonymous)
end
end

View file

@ -0,0 +1,65 @@
# frozen_string_literal: true
class Api::V1::Circles::StatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action :require_user!
before_action :set_circle
after_action :insert_pagination_headers, only: :show
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
private
def set_circle
@circle = current_account.circles.find(params[:circle_id])
end
def load_statuses
if unlimited?
@circle.statuses.includes(:status_stat).all
else
@circle.statuses.includes(:status_stat).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
end
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
return if unlimited?
api_v1_circle_statuses_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View file

@ -52,11 +52,11 @@ class Api::V1::FiltersController < Api::BaseController
end
def resource_params
params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :whole_word, context: [])
params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :whole_word, context: [])
end
def filter_params
resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :context)
resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :context)
end
def keyword_params

View file

@ -31,7 +31,7 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
end
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(
[@status], current_account.id, emoji_reactions_map: { @status.id => false }
[@status], current_account.id
)
rescue Mastodon::NotPermittedError
not_found

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
class Api::V1::Statuses::MentionedAccountsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
before_action :set_status
after_action :insert_pagination_headers
def index
cache_if_unauthenticated!
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
scope = default_accounts
scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
scope.merge(paginated_mentioned_users).to_a
end
def default_accounts
Account
.without_suspended
.includes(:mentions, :account_stat)
.references(:mentions)
.where(mentions: { status_id: @status.id })
end
def paginated_mentioned_users
Mention.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end
def pagination_max_id
@accounts.last.mentions.last.id
end
def pagination_since_id
@accounts.first.mentions.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show_mentioned_users?
rescue Mastodon::NotPermittedError
not_found
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View file

@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController
end
def resource_params
params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
end
end

View file

@ -10,7 +10,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :configure_sign_up_params, only: [:create]
before_action :set_sessions, only: [:edit, :update]
before_action :set_strikes, only: [:edit, :update]
before_action :set_instance_presenter, only: [:new, :create, :update]
before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update]
@ -107,10 +106,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_body_classes
@body_classes = %w(edit update).include?(action_name) ? 'admin' : 'lighter'
end

View file

@ -11,7 +11,6 @@ class Auth::SessionsController < Devise::SessionsController
include TwoFactorAuthenticationConcern
before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
content_security_policy only: :new do |p|
@ -99,10 +98,6 @@ class Auth::SessionsController < Devise::SessionsController
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_body_classes
@body_classes = 'lighter'
end

View file

@ -9,17 +9,11 @@ module AccountControllerConcern
FOLLOW_PER_PAGE = 12
included do
before_action :set_instance_presenter
after_action :set_link_headers, if: -> { request.format.nil? || request.format == :html }
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[

View file

@ -5,6 +5,7 @@ module TwoFactorAuthenticationConcern
included do
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
helper_method :webauthn_enabled?
end
def two_factor_enabled?
@ -87,4 +88,10 @@ module TwoFactorAuthenticationConcern
set_locale { render :two_factor }
end
protected
def webauthn_enabled?
@webauthn_enabled
end
end

View file

@ -49,7 +49,7 @@ class FiltersController < ApplicationController
end
def resource_params
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
params.require(:custom_filter).permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy])
end
def set_body_classes

View file

@ -3,15 +3,7 @@
class HomeController < ApplicationController
include WebAppControllerConcern
before_action :set_instance_presenter
def index
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View file

@ -5,15 +5,7 @@ class PrivacyController < ApplicationController
skip_before_action :require_functional!
before_action :set_instance_presenter
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
end

View file

@ -10,7 +10,6 @@ class StatusesController < ApplicationController
before_action :require_account_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_status
before_action :set_instance_presenter
before_action :redirect_to_original, only: :show
before_action :set_body_classes, only: :embed
@ -72,10 +71,6 @@ class StatusesController < ApplicationController
not_found
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def redirect_to_original
redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog?
end

View file

@ -14,7 +14,6 @@ class TagsController < ApplicationController
before_action :set_local
before_action :set_tag
before_action :set_statuses, if: -> { request.format == :rss }
before_action :set_instance_presenter
skip_before_action :require_functional!, unless: :limited_federation_mode?
@ -49,10 +48,6 @@ class TagsController < ApplicationController
@statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status)
end
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def limit_param
params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Admin::AnnouncementsHelper
def datetime_pattern
'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}'
end
def datetime_placeholder
Time.zone.now.strftime('%FT%R')
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
module InvitesHelper
def invites_max_uses_options
[1, 5, 10, 25, 50, 100]
end
def invites_expires_options
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week]
end
end

View file

@ -17,7 +17,8 @@ module KmyblueCapabilitiesHelper
:kmyblue_bookmark_category,
:kmyblue_quote,
:kmyblue_searchability_limited,
:kmyblue_visibility_public_unlisted,
:kmyblue_searchability_public_unlisted,
:kmyblue_circle_history,
]
capabilities << :profile_search unless Chewy.enabled?
@ -25,6 +26,32 @@ module KmyblueCapabilitiesHelper
capabilities << :emoji_reaction
capabilities << :enable_wide_emoji_reaction
end
capabilities << :kmyblue_visibility_public_unlisted if Setting.enable_public_unlisted_visibility
capabilities
end
def capabilities_for_nodeinfo
capabilities = %i(
wide_emoji
status_reference
quote
kmyblue_quote
searchability
kmyblue_searchability
visibility_mutual
visibility_limited
kmyblue_antenna
kmyblue_bookmark_category
kmyblue_searchability_limited
kmyblue_circle_history
)
capabilities << :full_text_search if Chewy.enabled?
if Setting.enable_emoji_reaction
capabilities << :emoji_reaction
capabilities << :enable_wide_emoji_reaction
end
capabilities
end

View file

@ -230,6 +230,24 @@ module LanguagesHelper
'sr-Latn': 'Srpski (latinica)',
}.freeze
# Helper for self.sorted_locale_keys
private_class_method def self.locale_name_for_sorting(locale)
if locale.blank? || locale == 'und'
'000'
elsif (supported_locale = SUPPORTED_LOCALES[locale.to_sym])
ASCIIFolding.new.fold(supported_locale[1]).downcase
elsif (regional_locale = REGIONAL_LOCALE_NAMES[locale.to_sym])
ASCIIFolding.new.fold(regional_locale).downcase
else
locale
end
end
# Sort locales by native name for dropdown menus
def self.sorted_locale_keys(locale_keys)
locale_keys.sort_by { |key, _| locale_name_for_sorting(key) }
end
def native_locale_name(locale)
if locale.blank? || locale == 'und'
I18n.t('generic.none')
@ -254,6 +272,7 @@ module LanguagesHelper
def valid_locale_or_nil(str)
return if str.blank?
return str if valid_locale?(str)
code, = str.to_s.split(/[_-]/) # Strip out the region from e.g. en_US or ja-JP

View file

@ -5,8 +5,6 @@ module MascotHelper
full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg'))
end
private
def instance_presenter
@instance_presenter ||= InstancePresenter.new
end

View file

@ -3,11 +3,12 @@
module RoutingHelper
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
included do
include Rails.application.routes.url_helpers
def default_url_options
ActionMailer::Base.default_url_options
end

View file

@ -2,7 +2,11 @@
module SettingsHelper
def filterable_languages
LanguagesHelper::SUPPORTED_LOCALES.keys
LanguagesHelper.sorted_locale_keys(LanguagesHelper::SUPPORTED_LOCALES.keys)
end
def ui_languages
LanguagesHelper.sorted_locale_keys(I18n.available_locales)
end
def session_device_icon(session)

View file

@ -56,4 +56,4 @@ export const showAlertForError = (error, skipNotFound = false) => {
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
}
};

View file

@ -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,
};
}

View file

@ -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,

View file

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

View file

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

View file

@ -80,6 +80,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog);
}
if (status.quote && status.quote.id) {
processStatus(status.quote);
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
}

View file

@ -85,6 +85,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
// for quoted post
if (!normalStatus.filtered && normalOldStatus.get('filtered')) {
normalStatus.filtered = normalOldStatus.get('filtered');
}
if (normalOldStatus.get('translation')) {
normalStatus.translation = normalOldStatus.get('translation');
}
@ -116,7 +121,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.media_attachments.forEach(item => {
const oldItem = list.find(i => i.get('id') === item.id);
if (oldItem && oldItem.get('description') === item.description) {
item.translation = oldItem.get('translation')
item.translation = oldItem.get('translation');
}
});
}
@ -160,13 +165,13 @@ export function normalizePoll(poll, normalOldPoll) {
...option,
voted: poll.own_votes && poll.own_votes.includes(index),
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}
};
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
}
return normalOption
return normalOption;
});
return normalPoll;

View file

@ -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,
};
}

View file

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

View file

@ -192,4 +192,4 @@ export const connectListStream = listId =>
* @returns {function(): void}
*/
export const connectAntennaStream = antennaId =>
connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) });
connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) });

View file

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

View file

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

View file

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

View file

@ -22,7 +22,7 @@ export default class Column extends PureComponent {
scrollable = document.scrollingElement;
} else {
scrollable = this.node.querySelector('.scrollable');
}
}
if (!scrollable) {
return;

View file

@ -0,0 +1,503 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import AttachmentList from 'mastodon/components/attachment_list';
import { Icon } from 'mastodon/components/icon';
import Card from '../features/status/components/card';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia } from '../initial_state';
import { Avatar } from './avatar';
import { DisplayName } from './display_name';
import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusContent from './status_content';
const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;
const values = [
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
spoilerText && status.get('hidden') ? spoilerText : contentText,
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
status.getIn(['account', 'acct']),
];
if (rebloggedByText) {
values.push(rebloggedByText);
}
return values.join(', ');
};
export const defaultMediaVisibility = (status) => {
if (!status) {
return undefined;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
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}' },
});
class CompactedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'muted',
'hidden',
'unread',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
statusId: undefined,
forceFilter: undefined,
};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
statusId: nextProps.status.get('id'),
};
} else {
return null;
}
}
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpen();
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this._openProfile(proper);
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
};
handleCollapsedToggle = isCollapsed => {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
};
handleTranslate = () => {
this.props.onTranslate(this._properStatus());
};
getAttachmentAspectRatio () {
const attachments = this._properStatus().get('media_attachments');
if (attachments.getIn([0, 'type']) === 'video') {
return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`;
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
}
}
renderLoadingMediaGallery = () => {
return (
<div className='media-gallery' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
renderLoadingVideoPlayer = () => {
return (
<div className='video-player' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
renderLoadingAudioPlayer = () => {
return (
<div className='audio-player' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
handleOpenVideo = (options) => {
const status = this._properStatus();
const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options);
};
handleOpenMedia = (media, index) => {
const status = this._properStatus();
const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenMedia(status.get('id'), media, index, lang);
};
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
e.preventDefault();
if (status.get('media_attachments').size > 0) {
const lang = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
} else {
onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang);
}
}
};
handleHotkeyOpen = () => {
if (this.props.onClick) {
this.props.onClick();
return;
}
const { router } = this.context;
const status = this._properStatus();
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
};
handleHotkeyOpenProfile = () => {
this._openProfile();
};
_openProfile = (proper = true) => {
const { router } = this.context;
const status = proper ? this._properStatus() : this.props.status;
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}`);
};
handleHotkeyMoveUp = e => {
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
};
handleHotkeyMoveDown = e => {
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
};
handleHotkeyToggleHidden = () => {
this.props.onToggleHidden(this._properStatus());
};
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
};
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
handleRef = c => {
this.node = c;
};
render () {
const { intl, hidden, featured, unread, showThread, previousId, nextInReplyToId, rootId } = this.props;
let { status } = this.props;
if (status === null) {
return null;
}
const handlers = this.props.muted ? {} : {
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
};
let media, isCardMediaWithSensitive, prepend, rebloggedByText;
if (hidden) {
return (
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span>
</div>
</HotKeys>
);
}
const connectUp = previousId && previousId === status.get('in_reply_to_id');
const connectToRoot = rootId && rootId === status.get('in_reply_to_id');
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
}
if (status.get('quote_muted')) {
const minHandlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className='status__wrapper status__wrapper__compact status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef} onClick={this.handleClick}>
<FormattedMessage id='status.quote_filtered' defaultMessage='This quote is filtered because of muting, blocking or domain blocking' />
</div>
</HotKeys>
);
}
isCardMediaWithSensitive = false;
if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (this.props.muted) {
media = (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={attachment.get('url')}
alt={description}
lang={language}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
compact
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
}
} else if (status.get('card') && !this.props.muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
sensitive={status.get('sensitive') && !status.get('spoiler_text')}
/>
);
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', 'status__wrapper__compact', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar status__avatar__compact'>
<Avatar account={status.get('account')} size={24} inline />
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent
status={status}
onClick={this.handleClick}
expanded={expanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsible
onCollapsedToggle={this.handleCollapsedToggle}
{...statusContentProps}
/>
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{(!status.get('spoiler_text') || expanded) && hashtagBar}
</div>
</div>
</HotKeys>
);
}
}
export default injectIntl(CompactedStatus);

View file

@ -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 }));
},
});

View file

@ -236,6 +236,7 @@ class MediaGallery extends PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
compact: PropTypes.bool,
};
state = {
@ -306,7 +307,7 @@ class MediaGallery extends PureComponent {
}
render () {
const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props;
const { media, lang, intl, sensitive, defaultWidth, autoplay, compact } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@ -355,13 +356,14 @@ class MediaGallery extends PureComponent {
const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' :
(size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' :
'media-gallery--row2';
'media-gallery--row2';
const columnClass = (size === 9) ? 'media-gallery--column3' :
(size === 10 || size === 11 || size === 12 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--column4' :
'media-gallery--column2';
'media-gallery--column2';
const compactClass = compact ? 'media-gallery__compact' : null;
return (
<div className={classNames('media-gallery', rowClass, columnClass)} style={style} ref={this.handleRef}>
<div className={classNames('media-gallery', rowClass, columnClass, compactClass)} style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>

View file

@ -132,7 +132,7 @@ class Poll extends ImmutablePureComponent {
handleReveal = () => {
this.setState({ revealed: true });
}
};
renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props;

View file

@ -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,
};
};

View file

@ -13,12 +13,13 @@ import AttachmentList from 'mastodon/components/attachment_list';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import CompactedStatusContainer from '../containers/compacted_status_container';
import Card from '../features/status/components/card';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline } from '../initial_state';
import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline, showQuoteInHome, showQuoteInPublic } from '../initial_state';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
@ -73,6 +74,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}' },
});
@ -86,6 +88,7 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
contextType: PropTypes.string,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
@ -210,7 +213,7 @@ class Status extends ImmutablePureComponent {
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
}
}
@ -356,15 +359,17 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
const { intl, hidden, featured, unread, muted, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
let { status, account, ...other } = this.props;
const contextType = (this.props.contextType || '').split(':')[0];
if (status === null) {
return null;
}
const handlers = this.props.muted ? {} : {
const handlers = muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
@ -383,7 +388,7 @@ class Status extends ImmutablePureComponent {
if (hidden) {
return (
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !muted })} tabIndex={0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span>
</div>
@ -405,30 +410,12 @@ 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) },
};
let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = this.props.muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
{' '}
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</div>
</HotKeys>
);
}
if (featured) {
prepend = (
<div className='status__prepend'>
@ -469,6 +456,63 @@ class Status extends ImmutablePureComponent {
);
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
if (status.get('filter_action') === 'half_warn') {
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div >
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
{' '}
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</div>
</div>
</HotKeys>
);
}
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
{' '}
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</div>
</HotKeys>
);
}
isCardMediaWithSensitive = false;
if (pictureInPicture.get('inUse')) {
@ -476,7 +520,7 @@ class Status extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (this.props.muted) {
if (muted) {
media = (
<AttachmentList
compact
@ -554,7 +598,7 @@ class Status extends ImmutablePureComponent {
</Bundle>
);
}
} else if (status.get('card') && !this.props.muted) {
} else if (status.get('card') && !muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
@ -566,12 +610,6 @@ class Status extends ImmutablePureComponent {
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
let emojiReactionsBar = null;
@ -586,20 +624,24 @@ class Status extends ImmutablePureComponent {
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
const withLimited = status.get('visibility_ex') === 'limited' && status.get('limited_scope') ? <span className='status__visibility-icon'><Icon id='get-pocket' title='Limited' /></span> : null;
const withReference = status.get('status_references_count') > 0 ? <span className='status__visibility-icon'><Icon id='link' title='Reference' /></span> : null;
const withQuote = status.get('quote_id') ? <span className='status__visibility-icon'><Icon id='quote-right' title='Quote' /></span> : null;
const withReference = (!withQuote && status.get('status_references_count') > 0) ? <span className='status__visibility-icon'><Icon id='link' title='Reference' /></span> : null;
const withExpiration = status.get('expires_at') ? <span className='status__visibility-icon'><Icon id='clock-o' title='Expiration' /></span> : null;
const quote = !muted && status.get('quote_id') && (['public', 'community'].includes(contextType) ? showQuoteInPublic : showQuoteInHome) && <CompactedStatusContainer id={status.get('quote_id')} />;
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !muted })} tabIndex={muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
{withQuote}
{withReference}
{withExpiration}
{withLimited}
@ -627,6 +669,8 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{(!status.get('spoiler_text') || expanded) && quote}
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{(!status.get('spoiler_text') || expanded) && hashtagBar}

View file

@ -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);
};
@ -293,6 +298,7 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']);
let menu = [];
@ -315,7 +321,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);
}
@ -323,7 +333,10 @@ class StatusActionBar extends ImmutablePureComponent {
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
if (allowQuote) {
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
}
}
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClickOriginal });

View file

@ -0,0 +1,78 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from '../actions/modal';
import {
hideStatus,
revealStatus,
toggleStatusCollapse,
translateStatus,
undoStatusTranslation,
} from '../actions/statuses';
import CompactedStatus from '../components/compacted_status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null,
pictureInPicture: getPictureInPicture(state, props),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onTranslate (status) {
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
} else {
dispatch(translateStatus(status.get('id')));
}
},
onOpenMedia (statusId, media, index, lang) {
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { statusId, media, index, lang },
}));
},
onOpenVideo (statusId, media, lang, options) {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { statusId, media, lang, options },
}));
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
onToggleCollapsed (status, isCollapsed) {
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
},
onInteractionModal (type, status) {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(CompactedStatus));

View file

@ -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 }));
},
});

View file

@ -80,6 +80,8 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
contextType,
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();

View file

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

View file

@ -33,7 +33,7 @@ class RadioPanel extends PureComponent {
<div className='setting-radio-panel'>
{values.map((val) => (
<button className={classNames('setting-radio-panel__item', {'setting-radio-panel__item__active': value.get('value') === val.get('value')})}
key={val.get('value')} onClick={this.handleChange} data-value={val.get('value')}>
key={val.get('value')} onClick={this.handleChange} data-value={val.get('value')}>
{val.get('label')}
</button>
))}

View file

@ -167,7 +167,7 @@ class AntennaSetting extends PureComponent {
handleEditAntennaClick = () => {
window.open(`/antennas/${this.props.params.id}/edit`, '_blank');
}
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
@ -193,7 +193,7 @@ class AntennaSetting extends PureComponent {
handleTimelineClick = () => {
this.context.router.history.push(`/antennast/${this.props.params.id}`);
}
};
onStlToggle = ({ target }) => {
const { dispatch } = this.props;
@ -336,7 +336,7 @@ class AntennaSetting extends PureComponent {
</label>
</div>
</>
)
);
}
let stlAlert;
@ -369,7 +369,7 @@ class AntennaSetting extends PureComponent {
const contentRadioAlert = antenna.get(contentRadioValue.get('value') === 'tags' ? 'keywords_count' : 'tags_count') > 0;
const listOptions = lists.toArray().filter((list) => list.length >= 2 && list[1]).map((list) => {
return { value: list[1].get('id'), label: list[1].get('title') }
return { value: list[1].get('id'), label: list[1].get('title') };
});
return (
@ -470,7 +470,7 @@ class AntennaSetting extends PureComponent {
icon='sitemap'
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
/>
/>
)}
{rangeRadioAlert && <div className='alert'><FormattedMessage id='antennas.warnings.range_radio' defaultMessage='Simultaneous account and domain designation is not recommended.' /></div>}
@ -487,7 +487,7 @@ class AntennaSetting extends PureComponent {
icon='hashtag'
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
/>
/>
)}
{contentRadioValue.get('value') === 'keywords' && (
@ -500,7 +500,7 @@ class AntennaSetting extends PureComponent {
icon='paragraph'
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
/>
/>
)}
{contentRadioAlert && <div className='alert'><FormattedMessage id='antennas.warnings.content_radio' defaultMessage='Simultaneous keyword and tag designation is not recommended.' /></div>}
@ -518,7 +518,7 @@ class AntennaSetting extends PureComponent {
icon='sitemap'
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
/>
/>
<h3><FormattedMessage id='antennas.exclude_keywords' defaultMessage='Exclude keywords' /></h3>
<TextList
onChange={this.onExcludeKeywordNameChanged}
@ -529,18 +529,18 @@ class AntennaSetting extends PureComponent {
icon='paragraph'
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
/>
<h3><FormattedMessage id='antennas.exclude_tags' defaultMessage='Exclude tags' /></h3>
<TextList
onChange={this.onExcludeTagNameChanged}
onAdd={this.onExcludeTagAdd}
onRemove={this.onExcludeTagRemove}
value={this.state.excludeTagName}
values={tags.get('exclude_tags') || ImmutableList()}
icon='hashtag'
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
/>
/>
<h3><FormattedMessage id='antennas.exclude_tags' defaultMessage='Exclude tags' /></h3>
<TextList
onChange={this.onExcludeTagNameChanged}
onAdd={this.onExcludeTagAdd}
onRemove={this.onExcludeTagRemove}
value={this.state.excludeTagName}
values={tags.get('exclude_tags') || ImmutableList()}
icon='hashtag'
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
/>
</>
)}
</div>

View file

@ -79,7 +79,7 @@ class Antennas extends ImmutablePureComponent {
>
{antennas.map(antenna => (
<ColumnLink key={antenna.get('id')} to={`/antennast/${antenna.get('id')}`} icon='wifi' text={antenna.get('title')}
badge={antenna.get('insert_feeds') ? intl.formatMessage(antenna.get('list') ? messages.insert_list : messages.insert_home) : undefined} />
badge={antenna.get('insert_feeds') ? intl.formatMessage(antenna.get('list') ? messages.insert_list : messages.insert_home) : undefined} />
))}
</ScrollableList>

View file

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

View file

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

View file

@ -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) => {

View file

@ -20,7 +20,7 @@ export default class NavigationBar extends ImmutablePureComponent {
};
render () {
const username = this.props.account.get('acct')
const username = this.props.account.get('acct');
return (
<div className='navigation-bar'>
<Link to={`/@${username}`}>

View file

@ -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');
}

View file

@ -57,17 +57,17 @@ class Search extends PureComponent {
};
defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
{ label: <><mark>my:</mark> <FormattedList type='disjunction' value={['favourited', 'bookmarked', 'boosted']} /></>, action: e => { e.preventDefault(); this._insertText('my:') } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
{ label: <><mark>domain:</mark> <FormattedMessage id='search_popout.domain' defaultMessage='domain' /></>, action: e => { e.preventDefault(); this._insertText('domain:') } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:') } },
{ label: <><mark>order:</mark> <FormattedList type='disjunction' value={['desc', 'asc']} /></>, action: e => { e.preventDefault(); this._insertText('order:') } },
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
{ label: <><mark>my:</mark> <FormattedList type='disjunction' value={['favourited', 'bookmarked', 'boosted']} /></>, action: e => { e.preventDefault(); this._insertText('my:'); } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
{ label: <><mark>domain:</mark> <FormattedMessage id='search_popout.domain' defaultMessage='domain' /></>, action: e => { e.preventDefault(); this._insertText('domain:'); } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
{ label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } },
{ label: <><mark>order:</mark> <FormattedList type='disjunction' value={['desc', 'asc']} /></>, action: e => { e.preventDefault(); this._insertText('order:'); } },
];
setRef = c => {

View file

@ -15,6 +15,8 @@ import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
public_long: { id: 'searchability.public.long', defaultMessage: 'Anyone can find' },
public_unlisted_short: { id: 'searchability.public_unlisted.short', defaultMessage: 'Public unlisted' },
public_unlisted_long: { id: 'searchability.public_unlisted.long', defaultMessage: 'Local users and followers can find' },
private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
private_long: { id: 'searchability.unlisted.long', defaultMessage: 'Your followers can find' },
direct_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
@ -223,6 +225,7 @@ class SearchabilityDropdown extends PureComponent {
this.options = [
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'cloud', value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) },
{ icon: 'unlock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'lock', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
{ icon: 'at', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) },

View file

@ -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 {

View file

@ -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']),
});

View file

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

View file

@ -80,7 +80,7 @@ class Results extends PureComponent {
}
return null;
};
}
handleSelectAll = () => {
const { submittedType, dispatch } = this.props;
@ -116,7 +116,7 @@ class Results extends PureComponent {
}
this.setState({ type: 'hashtags' });
}
};
handleSelectStatuses = () => {
const { submittedType, dispatch } = this.props;
@ -128,7 +128,7 @@ class Results extends PureComponent {
}
this.setState({ type: 'statuses' });
}
};
handleLoadMoreAccounts = () => this._loadMore('accounts');
handleLoadMoreStatuses = () => this._loadMore('statuses');

View file

@ -199,7 +199,7 @@ const Firehose = ({ feedType, multiColumn }) => {
</Helmet>
</Column>
);
}
};
Firehose.propTypes = {
multiColumn: PropTypes.bool,

View file

@ -27,9 +27,9 @@ const mapStateToProps = (state, { accountId }) => ({
const mapDispatchToProps = (dispatch) => ({
onSignupClick() {
dispatch(closeModal({
modalType: undefined,
ignoreFocus: false,
}));
modalType: undefined,
ignoreFocus: false,
}));
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
},
});
@ -187,7 +187,7 @@ class LoginForm extends React.PureComponent {
setIFrameRef = (iframe) => {
this.iframeRef = iframe;
}
};
handleFocus = () => {
this.setState({ expanded: true });

View file

@ -78,7 +78,7 @@ class Lists extends ImmutablePureComponent {
>
{lists.map(list =>
(<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')}
badge={(list.get('antennas') && list.get('antennas').size > 0) ? intl.formatMessage(messages.with_antenna) : undefined} />),
badge={(list.get('antennas') && list.get('antennas').size > 0) ? intl.formatMessage(messages.with_antenna) : undefined} />),
)}
</ScrollableList>

View file

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

View file

@ -59,7 +59,7 @@ class ReactionEmoji extends ImmutablePureComponent {
const html = { __html: emojify(emoji) };
content = (
<span dangerouslySetInnerHTML={html} />
)
);
}
return (

View file

@ -100,7 +100,7 @@ class ReactionDeck extends ImmutablePureComponent {
const newDeck = this.deckToArray();
newDeck.push('👍');
this.props.onChange(newDeck);
}
};
render () {
const { intl, deck, emojiMap, multiColumn } = this.props;
@ -123,38 +123,38 @@ class ReactionDeck extends ImmutablePureComponent {
showBackButton
/>
<ScrollableList
scrollKey='reaction_deck'
bindToDocument={!multiColumn}
>
<DragDropContext onDragEnd={this.handleReorder}>
<StrictModeDroppable droppableId='deckitems'>
{(provided) => (
<div className='deckitems reaction_deck_container' {...provided.droppableProps} ref={provided.innerRef}>
{deck.map((emoji, index) => (
<Draggable key={index} draggableId={'' + index} index={index}>
{(provided2) => (
<div className='reaction_deck_container__row' ref={provided2.innerRef} {...provided2.draggableProps}>
<Icon id='bars' className='handle' {...provided2.dragHandleProps} />
<ReactionEmoji emojiMap={emojiMap}
emoji={emoji.get('name')}
index={index}
onChange={this.handleChange}
onRemove={this.handleRemove}
className='reaction_emoji'
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
<ScrollableList
scrollKey='reaction_deck'
bindToDocument={!multiColumn}
>
<DragDropContext onDragEnd={this.handleReorder}>
<StrictModeDroppable droppableId='deckitems'>
{(provided) => (
<div className='deckitems reaction_deck_container' {...provided.droppableProps} ref={provided.innerRef}>
{deck.map((emoji, index) => (
<Draggable key={index} draggableId={'' + index} index={index}>
{(provided2) => (
<div className='reaction_deck_container__row' ref={provided2.innerRef} {...provided2.draggableProps}>
<Icon id='bars' className='handle' {...provided2.dragHandleProps} />
<ReactionEmoji emojiMap={emojiMap}
emoji={emoji.get('name')}
index={index}
onChange={this.handleChange}
onRemove={this.handleRemove}
className='reaction_emoji'
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
<Button text={intl.formatMessage(messages.reaction_deck_add)} onClick={this.handleAdd} />
</div>
)}
</StrictModeDroppable>
</DragDropContext>
</ScrollableList>
<Button text={intl.formatMessage(messages.reaction_deck_add)} onClick={this.handleAdd} />
</div>
)}
</StrictModeDroppable>
</DragDropContext>
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />

View file

@ -45,7 +45,7 @@ class Reblogs extends ImmutablePureComponent {
if (!this.props.accountIds) {
this.props.dispatch(fetchReblogs(this.props.params.statusId));
}
};
}
handleRefresh = () => {
this.props.dispatch(fetchReblogs(this.props.params.statusId));

View file

@ -104,7 +104,7 @@ const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedD
</div>
</>
);
}
};
Comment.propTypes = {
comment: PropTypes.string.isRequired,

View file

@ -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) },
};

View file

@ -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);
};
@ -231,6 +236,7 @@ class ActionBar extends PureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']);
let menu = [];
@ -254,7 +260,10 @@ class ActionBar extends PureComponent {
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
if (allowQuote) {
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
}
}
menu.push({ text: intl.formatMessage(messages.bookmark_category), action: this.handleBookmarkCategoryAdderClick });
@ -264,6 +273,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 });

View file

@ -35,8 +35,10 @@ 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_public_unlisted_short: { id: 'searchability.public_unlisted.short', defaultMessage: 'Public unlisted' },
searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
searchability_direct_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
searchability_limited_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
@ -145,7 +147,7 @@ class DetailedStatus extends ImmutablePureComponent {
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
}
}
@ -260,6 +262,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) },
};
@ -268,6 +271,7 @@ class DetailedStatus extends ImmutablePureComponent {
const searchabilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.searchability_public_short) },
'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.searchability_public_unlisted_short) },
'private': { icon: 'unlock', text: intl.formatMessage(messages.searchability_private_short) },
'direct': { icon: 'lock', text: intl.formatMessage(messages.searchability_direct_short) },
'limited': { icon: 'at', text: intl.formatMessage(messages.searchability_limited_short) },

View file

@ -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) },
};

View file

@ -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,

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