Merge branch 'kb_development' into kb_migration
This commit is contained in:
commit
9642305755
315 changed files with 3476 additions and 1385 deletions
102
.eslintrc.js
102
.eslintrc.js
|
@ -9,6 +9,7 @@ module.exports = {
|
|||
'plugin:import/recommended',
|
||||
'plugin:promise/recommended',
|
||||
'plugin:jsdoc/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
|
||||
env: {
|
||||
|
@ -54,28 +55,14 @@ module.exports = {
|
|||
'\\.(css|scss|json)$',
|
||||
],
|
||||
'import/resolver': {
|
||||
node: {
|
||||
paths: ['app/javascript'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
'brace-style': 'warn',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'comma-spacing': [
|
||||
'warn',
|
||||
{
|
||||
before: false,
|
||||
after: true,
|
||||
},
|
||||
],
|
||||
'comma-style': ['warn', 'last'],
|
||||
'consistent-return': 'error',
|
||||
'dot-notation': 'error',
|
||||
eqeqeq: ['error', 'always', { 'null': 'ignore' }],
|
||||
indent: ['warn', 2],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'no-case-declarations': 'off',
|
||||
'no-catch-shadow': 'error',
|
||||
|
@ -95,7 +82,6 @@ module.exports = {
|
|||
{ property: 'substr', message: 'Use .slice instead of .substr.' },
|
||||
],
|
||||
'no-self-assign': 'off',
|
||||
'no-trailing-spaces': 'warn',
|
||||
'no-unused-expressions': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
|
@ -107,30 +93,14 @@ module.exports = {
|
|||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'padded-blocks': [
|
||||
'error',
|
||||
{
|
||||
classes: 'always',
|
||||
},
|
||||
],
|
||||
quotes: ['error', 'single'],
|
||||
semi: 'error',
|
||||
'valid-typeof': 'error',
|
||||
|
||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }],
|
||||
'react/jsx-boolean-value': 'error',
|
||||
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
|
||||
'react/jsx-curly-spacing': 'error',
|
||||
'react/display-name': 'off',
|
||||
'react/jsx-equals-spacing': 'error',
|
||||
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
|
||||
'react/jsx-indent': ['error', 2],
|
||||
'react/jsx-no-bind': 'error',
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react/jsx-tag-spacing': 'error',
|
||||
'react/jsx-wrap-multilines': 'error',
|
||||
'react/no-deprecated': 'off',
|
||||
'react/no-unknown-property': 'off',
|
||||
'react/self-closing-comp': 'error',
|
||||
|
||||
|
@ -194,11 +164,14 @@ module.exports = {
|
|||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
mjs: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'import/first': 'error',
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-anonymous-default-export': 'error',
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
|
@ -213,6 +186,9 @@ module.exports = {
|
|||
'import/no-amd': 'error',
|
||||
'import/no-commonjs': 'error',
|
||||
'import/no-import-module-exports': 'error',
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-webpack-loader-syntax': 'error',
|
||||
|
||||
'promise/always-return': 'off',
|
||||
|
@ -284,6 +260,7 @@ module.exports = {
|
|||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
|
@ -291,10 +268,69 @@ module.exports = {
|
|||
'plugin:import/typescript',
|
||||
'plugin:promise/recommended',
|
||||
'plugin:jsdoc/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc' },
|
||||
'newlines-between': 'always',
|
||||
groups: [
|
||||
'builtin',
|
||||
'external',
|
||||
'internal',
|
||||
'parent',
|
||||
['index', 'sibling'],
|
||||
'object',
|
||||
],
|
||||
pathGroups: [
|
||||
// React core packages
|
||||
{
|
||||
pattern: '{react,react-dom,prop-types}',
|
||||
group: 'builtin',
|
||||
position: 'after',
|
||||
},
|
||||
// I18n
|
||||
{
|
||||
pattern: 'react-intl',
|
||||
group: 'builtin',
|
||||
position: 'after',
|
||||
},
|
||||
// Common React utilities
|
||||
{
|
||||
pattern: '{classnames,react-helmet}',
|
||||
group: 'external',
|
||||
position: 'before',
|
||||
},
|
||||
// Immutable / Redux / data store
|
||||
{
|
||||
pattern: '{immutable,react-redux,react-immutable-proptypes,react-immutable-pure-component,reselect}',
|
||||
group: 'external',
|
||||
position: 'before',
|
||||
},
|
||||
// Internal packages
|
||||
{
|
||||
pattern: '{mastodon/**}',
|
||||
group: 'internal',
|
||||
position: 'after',
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: [],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],
|
||||
'@typescript-eslint/consistent-type-exports': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
|
||||
|
|
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
|
@ -17,10 +17,6 @@ updates:
|
|||
- dependency-name: '@rails/ujs'
|
||||
versions:
|
||||
- '>= 7'
|
||||
# TODO: This was ignored in https://github.com/mastodon/mastodon/pull/19120
|
||||
- dependency-name: 'uuid'
|
||||
versions:
|
||||
- '>= 9'
|
||||
# TODO: This requires code changes for migration
|
||||
- dependency-name: 'tesseract.js'
|
||||
versions:
|
||||
|
|
10
.github/workflows/test-migrations-one-step.yml
vendored
10
.github/workflows/test-migrations-one-step.yml
vendored
|
@ -23,9 +23,17 @@ jobs:
|
|||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
postgres:
|
||||
- 14-alpine
|
||||
- 15-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
image: postgres:${{ matrix.postgres}}
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
|
|
10
.github/workflows/test-migrations-two-step.yml
vendored
10
.github/workflows/test-migrations-two-step.yml
vendored
|
@ -23,9 +23,17 @@ jobs:
|
|||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
postgres:
|
||||
- 14-alpine
|
||||
- 15-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
image: postgres:${{ matrix.postgres}}
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
|
|
|
@ -70,8 +70,6 @@ app/javascript/styles/mastodon/reset.scss
|
|||
# Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631
|
||||
*.js
|
||||
*.jsx
|
||||
*.ts
|
||||
*.tsx
|
||||
|
||||
# Ignore HTML till cleaned and included in CI
|
||||
*.html
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
module.exports = {
|
||||
singleQuote: true
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true
|
||||
}
|
||||
|
|
|
@ -132,6 +132,7 @@ Metrics/ClassLength:
|
|||
- 'app/models/user.rb'
|
||||
- 'app/serializers/activitypub/actor_serializer.rb'
|
||||
- 'app/serializers/activitypub/note_serializer.rb'
|
||||
- 'app/serializers/rest/account_serializer.rb'
|
||||
- 'app/serializers/rest/status_serializer.rb'
|
||||
- 'app/services/account_search_service.rb'
|
||||
- 'app/services/activitypub/process_account_service.rb'
|
||||
|
@ -165,7 +166,7 @@ Metrics/MethodLength:
|
|||
- 'lib/mastodon/*_cli.rb'
|
||||
|
||||
# Reason:
|
||||
# https://docs.rubocop.org/rubocop/cops_style.html#stylerescuestandarderror
|
||||
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength
|
||||
Metrics/ModuleLength:
|
||||
CountAsOne: [array, heredoc]
|
||||
|
||||
|
|
|
@ -94,11 +94,6 @@ Lint/AmbiguousBlockAssociation:
|
|||
- 'spec/services/unsuspend_account_service_spec.rb'
|
||||
- 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
Lint/AmbiguousOperatorPrecedence:
|
||||
Exclude:
|
||||
- 'config/initializers/rack_attack.rb'
|
||||
|
||||
# Configuration parameters: AllowComments, AllowEmptyLambdas.
|
||||
Lint/EmptyBlock:
|
||||
Exclude:
|
||||
|
@ -643,24 +638,6 @@ RSpec/RepeatedExampleGroupBody:
|
|||
Exclude:
|
||||
- 'spec/controllers/statuses_controller_spec.rb'
|
||||
|
||||
RSpec/RepeatedExampleGroupDescription:
|
||||
Exclude:
|
||||
- 'spec/controllers/admin/reports/actions_controller_spec.rb'
|
||||
- 'spec/policies/report_note_policy_spec.rb'
|
||||
|
||||
RSpec/ScatteredSetup:
|
||||
Exclude:
|
||||
- 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
|
||||
- 'spec/controllers/activitypub/outboxes_controller_spec.rb'
|
||||
- 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
|
||||
- 'spec/controllers/auth/registrations_controller_spec.rb'
|
||||
- 'spec/services/activitypub/process_account_service_spec.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
RSpec/SharedContext:
|
||||
Exclude:
|
||||
- 'spec/services/unsuspend_account_service_spec.rb'
|
||||
|
||||
RSpec/StubbedMock:
|
||||
Exclude:
|
||||
- 'spec/controllers/api/base_controller_spec.rb'
|
||||
|
@ -977,6 +954,8 @@ Rails/ThreeStateBooleanColumn:
|
|||
- 'db/migrate/20230314081013_add_group_allow_private_message_to_accounts.rb'
|
||||
- 'db/migrate/20230412005311_add_markdown_to_statuses.rb'
|
||||
- 'db/migrate/20230412073021_add_markdown_to_status_edits.rb'
|
||||
- 'db/migrate/20230428111230_add_emoji_reaction_streaming_to_accounts.rb'
|
||||
- 'db/migrate/20230510004621_remove_stop_emoji_reaction_streaming_from_accounts.rb'
|
||||
|
||||
# Configuration parameters: Include.
|
||||
# Include: app/models/**/*.rb
|
||||
|
|
43
CHANGELOG_KB.md
Normal file
43
CHANGELOG_KB.md
Normal file
|
@ -0,0 +1,43 @@
|
|||
# kmyblue の変更履歴
|
||||
|
||||
## 4.2.0 kb-RC1 - 2023/5/23
|
||||
|
||||
### 追加
|
||||
|
||||
- カスタム絵文字の`isSensitive`値のサポート (from Misskey)
|
||||
- カスタム絵文字の検索用のエイリアスキーワード
|
||||
- アンテナの STL(ソーシャルタイムライン)モード
|
||||
- アカウントの投稿数、フォロー数、フォロワー数の隠蔽
|
||||
- @WEB - ブーストされた投稿において、ブースト自体の公開範囲の表示
|
||||
- @WEB - 投稿をブーストするとき、ユーザー設定に関わらず常にダイアログを表示するメニュー項目
|
||||
- ダイアログによりブースト自体の公開範囲が指定可能
|
||||
- @WEB - 設定画面において、kmyblue 独自の設定項目にマーク
|
||||
- ブースト時に公開範囲を指定していなかった場合、デフォルトで適用される公開範囲の設定
|
||||
- 投稿文章や画像の AI 利用に対して不快感を表明する設定
|
||||
- スタンプのストリーミングを停止する設定
|
||||
- Glitch-soc 互換スタンプ API(※当該 PR はまだ Glitch で審査中)
|
||||
- アンテナでブーストを無視する設定
|
||||
- 投稿につけられたスタンプ総数の表示
|
||||
- @WEB - ユーザーメニューにアンテナの項目
|
||||
- 投稿自動削除機能にスタンプ条件指定
|
||||
|
||||
### 変更
|
||||
|
||||
- @WEB - 横長絵文字の絵文字ピッカー内における表示スタイル
|
||||
- 画像のない投稿のセンシティブフラグについて、指定がない場合に限り常に`false`
|
||||
- 「絵文字リアクション」を「スタンプ」に改称
|
||||
- アカウントのフィールドの最大数を4から6に拡張
|
||||
- Searchability API を Fedibird 互換に
|
||||
- @WEB - 表示可能な画像の最大数を8から16に引き上げ
|
||||
- 全文検索の時系列順表示を廃止
|
||||
- 画像添付と投票を一つの投稿で共存可能
|
||||
- @WEB - 右側サイドメニューに表示されるリスト数を4から8に引き上げ
|
||||
- ホーム・リストの投稿保持数を800から1000に引き上げ
|
||||
- @ADMIN - アカウントの表示名、ID の正規表現検索
|
||||
|
||||
### 修正
|
||||
|
||||
- スタンプが投稿のキャッシュに反映・更新されない問題
|
||||
- デバッグ時に発生する`outbox`の一部エラー
|
||||
- スタンプを削除する API において、API の戻り値に削除されたスタンプが残っている問題
|
||||
- 休眠ユーザーにアンテナの投稿が配信される問題
|
|
@ -55,7 +55,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|||
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"
|
||||
|
||||
# Ignoreing these here since we don't want to pin any versions and the Debian image removes apt-get content after use
|
||||
# Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use
|
||||
# hadolint ignore=DL3008,DL3009
|
||||
RUN apt-get update && \
|
||||
echo "Etc/UTC" > /etc/localtime && \
|
||||
|
|
87
Gemfile
87
Gemfile
|
@ -99,54 +99,87 @@ gem 'json-ld'
|
|||
gem 'json-ld-preloaded', '~> 3.2'
|
||||
gem 'rdf-normalize', '~> 0.5'
|
||||
|
||||
group :development, :test do
|
||||
gem 'fabrication', '~> 2.30'
|
||||
gem 'fuubar', '~> 2.5'
|
||||
gem 'i18n-tasks', '~> 1.0', require: false
|
||||
gem 'rspec-rails', '~> 6.0'
|
||||
gem 'rspec_chunked', '~> 0.6'
|
||||
|
||||
gem 'rubocop-capybara', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop', require: false
|
||||
end
|
||||
|
||||
group :production, :test do
|
||||
gem 'private_address_check', '~> 0.5'
|
||||
end
|
||||
gem 'private_address_check', '~> 0.5'
|
||||
|
||||
group :test do
|
||||
gem 'capybara', '~> 3.39'
|
||||
gem 'climate_control'
|
||||
gem 'faker', '~> 3.2'
|
||||
gem 'json-schema', '~> 4.0'
|
||||
gem 'rack-test', '~> 2.1'
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
gem 'rspec_junit_formatter', '~> 0.6'
|
||||
# RSpec runner for rails
|
||||
gem 'rspec-rails', '~> 6.0'
|
||||
|
||||
# Used to split testing into chunks in CI
|
||||
gem 'rspec_chunked', '~> 0.6'
|
||||
|
||||
# RSpec progress bar formatter
|
||||
gem 'fuubar', '~> 2.5'
|
||||
|
||||
# Extra RSpec extenion methods and helpers for sidekiq
|
||||
gem 'rspec-sidekiq', '~> 3.1'
|
||||
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
|
||||
# Used to mock environment variables
|
||||
gem 'climate_control', '~> 0.2'
|
||||
|
||||
# Generating fake data for specs
|
||||
gem 'faker', '~> 3.2'
|
||||
|
||||
# Generate test objects for specs
|
||||
gem 'fabrication', '~> 2.30'
|
||||
|
||||
# Add back helpers functions removed in Rails 5.1
|
||||
gem 'rails-controller-testing', '~> 1.0'
|
||||
|
||||
# Validate schemas in specs
|
||||
gem 'json-schema', '~> 4.0'
|
||||
|
||||
# Test harness fo rack components
|
||||
gem 'rack-test', '~> 2.1'
|
||||
|
||||
# Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false
|
||||
gem 'simplecov', '~> 0.22', require: false
|
||||
|
||||
# Stub web requests for specs
|
||||
gem 'webmock', '~> 3.18'
|
||||
end
|
||||
|
||||
group :development do
|
||||
# Code linting CLI and plugins
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-capybara', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
|
||||
# Annotates modules with schema
|
||||
gem 'annotate', '~> 3.2'
|
||||
|
||||
# Enhanced error message pages for development
|
||||
gem 'better_errors', '~> 2.9'
|
||||
gem 'binding_of_caller', '~> 1.0'
|
||||
|
||||
# Preview mail in the browser
|
||||
gem 'letter_opener', '~> 1.8'
|
||||
gem 'letter_opener_web', '~> 2.0'
|
||||
gem 'memory_profiler'
|
||||
|
||||
# Security analysis CLI tools
|
||||
gem 'brakeman', '~> 5.4', require: false
|
||||
gem 'bundler-audit', '~> 0.9', require: false
|
||||
|
||||
# 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'
|
||||
|
||||
gem 'stackprof'
|
||||
# Validate missing i18n keys
|
||||
gem 'i18n-tasks', '~> 1.0', require: false
|
||||
|
||||
# Profiling tools
|
||||
gem 'memory_profiler', require: false
|
||||
gem 'stackprof', require: false
|
||||
end
|
||||
|
||||
group :production do
|
||||
|
@ -160,3 +193,5 @@ gem 'cocoon', '~> 1.2'
|
|||
|
||||
gem 'net-http', '~> 0.3.2'
|
||||
gem 'rubyzip', '~> 2.3'
|
||||
|
||||
gem 'hcaptcha', '~> 7.1'
|
||||
|
|
16
Gemfile.lock
16
Gemfile.lock
|
@ -166,7 +166,7 @@ GEM
|
|||
sshkit (~> 1.3)
|
||||
capistrano-yarn (2.0.2)
|
||||
capistrano (~> 3.0)
|
||||
capybara (3.39.0)
|
||||
capybara (3.39.1)
|
||||
addressable
|
||||
matrix
|
||||
mini_mime (>= 0.1.3)
|
||||
|
@ -312,6 +312,8 @@ GEM
|
|||
sysexits (~> 1.1)
|
||||
hashdiff (1.0.1)
|
||||
hashie (5.0.0)
|
||||
hcaptcha (7.1.0)
|
||||
json
|
||||
highline (2.1.0)
|
||||
hiredis (0.6.3)
|
||||
hkdf (0.3.0)
|
||||
|
@ -329,7 +331,7 @@ GEM
|
|||
httplog (1.6.2)
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.12.0)
|
||||
i18n (1.13.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.12)
|
||||
activesupport (>= 4.0.2)
|
||||
|
@ -416,7 +418,7 @@ GEM
|
|||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.8.1)
|
||||
mini_portile2 (2.8.2)
|
||||
minitest (5.18.0)
|
||||
msgpack (1.7.0)
|
||||
multi_json (1.15.0)
|
||||
|
@ -599,8 +601,6 @@ GEM
|
|||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.12.0)
|
||||
rspec_chunked (0.6)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.50.2)
|
||||
json (~> 2.3)
|
||||
parallel (~> 1.10)
|
||||
|
@ -696,7 +696,7 @@ GEM
|
|||
unicode-display_width (>= 1.1.1, < 3)
|
||||
terrapin (0.6.0)
|
||||
climate_control (>= 0.0.3, < 1.0)
|
||||
thor (1.2.1)
|
||||
thor (1.2.2)
|
||||
tilt (2.1.0)
|
||||
timeout (0.3.2)
|
||||
tpm-key_attestation (0.12.0)
|
||||
|
@ -785,7 +785,7 @@ DEPENDENCIES
|
|||
capybara (~> 3.39)
|
||||
charlock_holmes (~> 0.7.7)
|
||||
chewy (~> 7.3)
|
||||
climate_control
|
||||
climate_control (~> 0.2)
|
||||
cocoon (~> 1.2)
|
||||
color_diff (~> 0.1)
|
||||
concurrent-ruby
|
||||
|
@ -806,6 +806,7 @@ DEPENDENCIES
|
|||
fuubar (~> 2.5)
|
||||
haml-rails (~> 2.0)
|
||||
haml_lint
|
||||
hcaptcha (~> 7.1)
|
||||
hiredis (~> 0.6)
|
||||
htmlentities (~> 4.3)
|
||||
http (~> 5.1)
|
||||
|
@ -863,7 +864,6 @@ DEPENDENCIES
|
|||
rspec-rails (~> 6.0)
|
||||
rspec-sidekiq (~> 3.1)
|
||||
rspec_chunked (~> 0.6)
|
||||
rspec_junit_formatter (~> 0.6)
|
||||
rubocop
|
||||
rubocop-capybara
|
||||
rubocop-performance
|
||||
|
|
155
README.md
155
README.md
|
@ -1,111 +1,130 @@
|
|||
<h1><picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./lib/assets/wordmark.dark.png?raw=true">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./lib/assets/wordmark.light.png?raw=true">
|
||||
<img alt="Mastodon" src="./lib/assets/wordmark.light.png?raw=true" height="34">
|
||||
</picture></h1>
|
||||
# kmyblue
|
||||
|
||||
[][releases]
|
||||
[](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml)
|
||||
[][crowdin]
|
||||
kmyblue は[Mastodon](https://github.com/mastodon/mastodon)のフォークです。創作作家のための Mastodon を目指して開発しました。
|
||||
|
||||
[releases]: https://github.com/mastodon/mastodon/releases
|
||||
[crowdin]: https://crowdin.com/project/mastodon
|
||||
kmyblue はフォーク名であり、同時に[サーバー名](https://kmy.blue)でもあります。以下は特に記述がない限り、フォークとしての kmyblue をさします。
|
||||
|
||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||
kmyblue は AGPL ライセンスで公開されているため、どなたでも自由にフォークし、このソースコードを元に自分でサーバーを立てて公開することができます。また ActivityPub に参加することもできます。サーバーkmyblueは創作作家向けのものですが、フォークとしてのkmyblueは作者の嫌いな政治に関する過激な話を取り扱うコミュニティ、創作活動の一部(エロ関係含む)または全体を否定するコミュニティなども平等にお使いいただけますし、サーバーkmyblueのルールを適用する必要もありません。
|
||||
ただし kmyblue においてテストコードは飾りでしかないため、不具合が発生しても自己責任になります。既知のバグもいくつかありますし、直す予定のないものも含まれます。
|
||||
|
||||
Click below to **learn more** in a video:
|
||||
テストコードは飾りですが、Lint は動いています。
|
||||
|
||||
[][youtube_demo]
|
||||
## kmyblue の強み
|
||||
|
||||
[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE
|
||||
### 本家 Mastodon への積極的追従
|
||||
|
||||
## Navigation
|
||||
kmyblue は、いくつかのフォークと異なり、追加機能を控えめにする代わりに本家 Mastodon に積極的に追従を行います。バージョン 4 には 4 のよさがありますが、技術的に可能である限り、バージョン 5 へのアップグレードもやぶさかではありません。
|
||||
kmyblue の追加機能そのままに、Mastodon の新機能も利用できるよう調整を行います。
|
||||
|
||||
- [Project homepage 🐘](https://joinmastodon.org)
|
||||
- [Support the development via Patreon][patreon]
|
||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||
- [Blog](https://blog.joinmastodon.org)
|
||||
- [Documentation](https://docs.joinmastodon.org)
|
||||
- [Roadmap](https://joinmastodon.org/roadmap)
|
||||
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
||||
### 絵文字リアクション対応
|
||||
|
||||
[patreon]: https://www.patreon.com/mastodon
|
||||
kmyblue は絵文字リアクションに対応しているフォークの1つです。絵文字リアクションは Misskey 標準搭載の機能で、需要が高い機能である割には、サーバーに負荷がかかるため本家 Mastodon には搭載されていません。
|
||||
|
||||
## Features
|
||||
## kmyblue のブランチ
|
||||
|
||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||
- **main** - 現在はメンテナンスされていません
|
||||
- **kb_development** - 現在 kmyblue 本体で使われているソースコードです
|
||||
- **kb_migration** - 本家 Mastodon への追従を目的としたブランチです。`kb_development`上で開発を進めているときに利用します
|
||||
- **kb_migration_development** - 本家 Mastodon へ追従し、かつその上で開発するときに使うブランチです。最新の本家コードでリファクタリングが行われ、`kb_development`と`kb_migration`の互換性の維持が困難になったときに利用します。ここで追加された機能は原則、本家 Mastodon のバージョンアップと同時に`kb_development`に反映されます
|
||||
|
||||
### No vendor lock-in: Fully interoperable with any conforming platform
|
||||
## 本家 Mastodon からの追加機能
|
||||
|
||||
It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
||||
kmyblue は、本家 Mastodon にいくつかの改造を加えています。以下に示します。
|
||||
|
||||
### Real-time, chronological timeline updates
|
||||
### ローカル公開
|
||||
|
||||
Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
||||
未収載を拡張した公開範囲です。本来のみ収載の公開範囲に加えて、自分のサーバーのローカルタイムラインに掲載されます。他のサーバーの連合タイムラインに載せたくない、自分と属性の近い人達が集まったサーバーと自分のフォロワーにだけ見せたい投稿に用います。
|
||||
|
||||
### Media attachments like images and short videos
|
||||
### スタンプ(絵文字リアクション)
|
||||
|
||||
Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
||||
kmyblue 内での呼称はスタンプですが、一般には絵文字リアクションと呼ばれる機能です。自分や他人の投稿に絵文字をつけることができます。kmyblue のスタンプは Fedibird の絵文字リアクションと互換性のある API を持っているため、Fedibird 対応アプリで kmyblue のスタンプ機能を利用できる場合があります。
|
||||
|
||||
### Safety and moderation tools
|
||||
kmyblue は、1つのアカウントが1つの投稿に複数のスタンプ(絵文字リアクション)を最大3個までつけることが可能です。また、下記機能にも対応しています。
|
||||
|
||||
Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
||||
- 他のサーバーのアカウントがつけたスタンプに相乗りする
|
||||
- 自分がスタンプを付けた投稿一覧を見る
|
||||
- トレンド投稿の選定条件にスタンプを付けたアカウントの数を考慮する
|
||||
- 投稿の自動削除で削除条件にスタンプの数を指定する
|
||||
|
||||
### OAuth2 and a straightforward REST API
|
||||
kmyblue は、他のサーバーの投稿にスタンプをつけることで、相手サーバーに情報を送信します。ただしスタンプに対応していないサーバーにおいては、通知されることはありません。
|
||||
|
||||
Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
||||
### アンテナ
|
||||
|
||||
## Deployment
|
||||
「自分はフォローしていないが連合タイムラインに流れているアカウント」の投稿を購読することが可能です。アンテナはドメイン、アカウント、ハッシュタグ、キーワードの4種類の絞り込み条件を持ち、複合指定することで AND 条件として働きます。アンテナによって検出された投稿は、指定されたリスト、またはホームタイムラインに追加されます。
|
||||
|
||||
### Tech stack:
|
||||
ドメイン購読において、自分自身のドメインを指定できることも特長の1つです。また、STL(ソーシャルタイムライン)モードにも対応しています。
|
||||
|
||||
- **Ruby on Rails** powers the REST API and other web pages
|
||||
- **React.js** and Redux are used for the dynamic parts of the interface
|
||||
- **Node.js** powers the streaming API
|
||||
自分の投稿がアンテナに検出されるのを拒否することもできます。この拒否設定は、ActivityPub で他サーバーにも共有されますが、対応するかはそれぞれの判断に委ねられます。
|
||||
|
||||
### Requirements:
|
||||
### 期間限定投稿
|
||||
|
||||
- **PostgreSQL** 9.5+
|
||||
- **Redis** 4+
|
||||
- **Ruby** 2.7+
|
||||
- **Node.js** 14+
|
||||
例えば`#exp10m`タグをつけると、その投稿は 10 分後に自動削除されます。秒、分、時、日の4種類の指定に対応、数値は4桁まで、5 分未満の時間指定も可能ですが編集が絡むと意図通り動作しないことがあります。
|
||||
|
||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
||||
このハッシュタグがついた投稿を編集しても、実際に削除される時刻は編集時刻ではなく、投稿時刻から起算されます。
|
||||
|
||||
A **Vagrant** configuration is included for development purposes. To use it, complete following steps:
|
||||
### グループ
|
||||
|
||||
- Install Vagrant and Virtualbox
|
||||
- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater`
|
||||
- Run `vagrant up`
|
||||
- Run `vagrant ssh -c "cd /vagrant && foreman start"`
|
||||
- Open `http://mastodon.local` in your browser
|
||||
kmyblue はグループ機能に対応しています。グループのフォロワーからグループアカウントへのメンションはすべてブーストされ、グループのフォロワー全員に届きます。なおこれは本家 Mastodon でも今後実装予定の機能です。
|
||||
|
||||
### Getting Started with GitHub Codespaces
|
||||
### ドメインブロックの拡張
|
||||
|
||||
To get started, create a codespace for this repository by clicking this 👇
|
||||
ドメインブロック機能が大幅に拡張され、「制限」「停止」としなくても、細分化された操作を個別にブロックすることができます。これによって、相手サーバーとの交流が完全に遮断されるリスクを減らします。適切に管理されておらず善良なユーザーとスパム/攻撃者が同居するようなサーバーへの対策として有効です。
|
||||
|
||||
[](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283)
|
||||
- お気に入り・絵文字リアクションのみをブロック
|
||||
- メンションのみをブロック
|
||||
- 相手からのフォローを強制的に審査制にする
|
||||
- 相手からのフォローを全てブロック
|
||||
|
||||
A codespace will open in a web-based version of Visual Studio Code. The [dev container](.devcontainer/devcontainer.json) is fully configured with software needed for this project.
|
||||
他にも、「自分のサーバーの特定投稿を相手サーバーに送信しない」設定が可能です。これはセンシティブな投稿などを政治的な理由で送信することが難しいサーバーへの対策として実装しました。
|
||||
|
||||
**Note**: Dev containers is an open spec which is supported by [GitHub Codespaces](https://github.com/codespaces) and [other tools](https://containers.dev/supporting).
|
||||
#### Misskey 対策
|
||||
|
||||
## Contributing
|
||||
Misskey およびそのフォーク(Calckey など)は、**フォローしていないアカウントの未収載投稿**を自由に検索・購読することができます。これは Mastodon の設計とは根本的に異なる仕様です。kmyblue では、別途手動でドメインブロックデータにフラグを付けたサーバーに限り、そのサーバーに未収載投稿を送信するときに「フォロワーのみ」に変更します。他のサーバーには未収載として送信されます。この動作には、管理者だけでなくユーザー各自の設定も必要になります。
|
||||
|
||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
||||
### モデレーションの拡張
|
||||
|
||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
|
||||
管理の効率化、規約違反・法律違反コンテンツへの迅速な対応(特にアカウント停止を伴わずに済むようにすること)を目的として、モデレーション機能を拡張しています。
|
||||
|
||||
**IRC channel**: #mastodon on irc.libera.chat
|
||||
#### 各投稿の操作
|
||||
|
||||
## License
|
||||
各投稿について、強制的な CW 付与、強制的な画像 NSFW フラグ付与、編集履歴の削除、画像の削除、投稿の削除が行なえます。操作は即時反映されます。
|
||||
|
||||
Copyright (C) 2016-2022 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
|
||||
#### アカウントの正規表現検索
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
アカウント名、表示名について正規表現で検索できます。ただし動作は重くなります。
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||
#### 全画像の閲覧
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
「未収載」「フォロワーのみ」「指定された相手のみ」公開範囲のものも含め、すべての画像を閲覧できる画面があります。法令、サーバー規約に違反した画像を見つけるために必要です。
|
||||
|
||||
### AI 学習禁止メタタグ
|
||||
|
||||
ユーザー生成コンテンツが含まれる全てのページに、AI 学習を禁止するメタタグを挿入しています。ただし各ユーザーのプロフィールページ・投稿ページに限り、ユーザーは各自設定で AI 学習禁止メタタグを除去することが可能です。
|
||||
|
||||
### リンクのテキストと実際のリンク先の異なるものの強調表示
|
||||
|
||||
Misskey およびそのフォーク(Calckey など)では、MFM を利用することにより、例えば「https://google.co.jp/ 」に向けたリンクを「https://www.yahoo.co.jp/ 」というテキストで掲載することが容易です。それに関する基本的な詐欺を見分けることができます。ただし実際にフィッシング詐欺への効果があるかは疑問です。
|
||||
|
||||
### 投票項目数の拡張
|
||||
|
||||
投票について、本家 Mastodon では4項目までですが、kmyblue では8個までに拡張しています。
|
||||
|
||||
### 連合から送られてくる投稿の添付画像最大数の拡張
|
||||
|
||||
本家 Mastodon では4個までですが、kmyblue では8個までに拡張しています。ただし Web クライアントでの表示には、各自ユーザーによる設定が必要です。kmyblue ローカルから投稿できる画像の枚数に変更はありません。
|
||||
|
||||
### 検索許可
|
||||
|
||||
ユーザーは各投稿に「検索許可」パラメータを付与できます。ここで「公開」が指定された投稿は、誰でも自由に検索機能を用いて検索することができます(全文検索に限る)。検索許可に対応していないクライアントアプリから投稿した時の値は、ユーザーが設定することができます。
|
||||
|
||||
これは Fedibird の「検索範囲」機能に対応します。API に互換性はありませんが、ActivityPub 仕様は共通しています。
|
||||
|
||||
### トレンドの拡張
|
||||
|
||||
本家マストドンでは、センシティブフラグのついた全ての投稿がトレンドに掲載されません。kmyblue はその中でも、「センシティブフラグはついているが、画像が添付されておらず CW 付きでもない」投稿をトレンドに掲載します。
|
||||
|
||||
本来このような投稿はトレンドに掲載すべきでありませんが、本家 Mastodon の Web クライアントでは文章だけの投稿のセンシティブフラグを自由に操作できないことを理由とした独自対応となります。
|
||||
|
||||
### Sidekiq ヘルスチェック
|
||||
|
||||
Sidekiq のヘルスチェックを目的として、30秒に1回ずつ指定された URL に HEAD リクエストを送信します。送信先 URL は、環境変数(または`.env.production`)に`SIDEKIQ_HEALTH_FETCH_URL`として指定します。
|
||||
|
|
|
@ -36,8 +36,8 @@ class AccountsIndex < Chewy::Index
|
|||
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content'
|
||||
end
|
||||
|
||||
field :following_count, type: 'long', value: ->(account) { account.following_count }
|
||||
field :followers_count, type: 'long', value: ->(account) { account.followers_count }
|
||||
field :following_count, type: 'long', value: ->(account) { account.public_following_count }
|
||||
field :followers_count, type: 'long', value: ->(account) { account.public_followers_count }
|
||||
field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -37,7 +37,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
|
|||
ActivityPub::CollectionPresenter.new(
|
||||
id: outbox_url,
|
||||
type: :ordered,
|
||||
size: @account.statuses_count,
|
||||
size: @account.user&.setting_hide_statuses_count ? 0 : @account.statuses_count,
|
||||
first: outbox_url(page: true),
|
||||
last: outbox_url(page: true, min_id: 0)
|
||||
)
|
||||
|
|
|
@ -74,7 +74,7 @@ module Admin
|
|||
end
|
||||
|
||||
def form_custom_emoji_batch_params
|
||||
params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
|
||||
params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, :aliases_raw, custom_emoji_ids: [])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -78,15 +78,15 @@ module Admin
|
|||
end
|
||||
|
||||
def update_params
|
||||
params.require(:domain_block).permit(:severity, :reject_media, :reject_favourite, :reject_reply, :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)
|
||||
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)
|
||||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :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)
|
||||
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)
|
||||
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_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])
|
||||
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])
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
|
|
|
@ -59,7 +59,7 @@ class AntennasController < ApplicationController
|
|||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:antenna).permit(:title, :list, :available, :expires_in, :with_media_only, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw)
|
||||
params.require(:antenna).permit(:title, :list, :available, :stl, :expires_in, :with_media_only, :ignore_reblog, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw)
|
||||
end
|
||||
|
||||
def thin_resource_params
|
||||
|
|
|
@ -58,7 +58,7 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
|
|||
end
|
||||
|
||||
def set_canonical_email_blocks_from_test
|
||||
@canonical_email_blocks = CanonicalEmailBlock.matching_email(params[:email])
|
||||
@canonical_email_blocks = CanonicalEmailBlock.matching_email(params.require(:email))
|
||||
end
|
||||
|
||||
def set_canonical_email_block
|
||||
|
|
|
@ -69,7 +69,7 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
|
|||
end
|
||||
|
||||
def domain_block_params
|
||||
params.permit(:severity, :reject_media, :reject_favourite, :reject_reply, :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)
|
||||
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)
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
|
@ -101,6 +101,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
|
|||
end
|
||||
|
||||
def resource_params
|
||||
params.permit(:domain, :severity, :reject_media, :reject_favourite, :reject_reply, :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)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Emails::ConfirmationsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user_owned_by_application!
|
||||
before_action :require_user_not_confirmed!
|
||||
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :check
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check
|
||||
before_action :require_user_owned_by_application!, except: :check
|
||||
before_action :require_user_not_confirmed!, except: :check
|
||||
|
||||
def create
|
||||
current_user.update!(email: params[:email]) if params.key?(:email)
|
||||
|
@ -12,6 +13,10 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController
|
|||
render_empty
|
||||
end
|
||||
|
||||
def check
|
||||
render json: current_user.confirmed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_user_owned_by_application!
|
||||
|
|
|
@ -9,7 +9,7 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
|
|||
before_action :set_status_without_authorize, only: [:destroy]
|
||||
|
||||
def create
|
||||
create_private(params[:emoji])
|
||||
create_private(params[:emoji] || params[:id])
|
||||
end
|
||||
|
||||
# For compatible with Fedibird API
|
||||
|
@ -26,9 +26,9 @@ class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
|
|||
.find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain }
|
||||
|
||||
authorize @status, :show? if emoji_reaction.nil?
|
||||
end
|
||||
|
||||
UnEmojiReactWorker.perform_async(current_account.id, @status.id, emoji)
|
||||
UnEmojiReactService.new.call(current_account.id, @status.id, emoji_reaction) if emoji_reaction.present?
|
||||
end
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(
|
||||
[@status], current_account.id, emoji_reactions_map: { @status.id => false }
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
class Api::V1::Statuses::ReblogsController < Api::BaseController
|
||||
include Authorization
|
||||
include Redisable
|
||||
include Lockable
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||
before_action :require_user!
|
||||
|
@ -10,7 +12,9 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||
override_rate_limit_headers :create, family: :statuses
|
||||
|
||||
def create
|
||||
@status = ReblogService.new.call(current_account, @reblog, reblog_params)
|
||||
with_redis_lock("reblog:#{current_account.id}:#{@reblog.id}") do
|
||||
@status = ReblogService.new.call(current_account, @reblog, reblog_params)
|
||||
end
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
|
|
@ -1,21 +1,63 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||
include CaptchaConcern
|
||||
|
||||
layout 'auth'
|
||||
|
||||
before_action :set_body_classes
|
||||
before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
|
||||
before_action :require_unconfirmed!
|
||||
|
||||
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
|
||||
before_action :require_captcha_if_needed!, only: [:show]
|
||||
|
||||
skip_before_action :require_functional!
|
||||
|
||||
def show
|
||||
old_session_values = session.to_hash
|
||||
reset_session
|
||||
session.update old_session_values.except('session_id')
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def new
|
||||
super
|
||||
|
||||
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
|
||||
end
|
||||
|
||||
def confirm_captcha
|
||||
check_captcha! do |message|
|
||||
flash.now[:alert] = message
|
||||
render :captcha
|
||||
return
|
||||
end
|
||||
|
||||
show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_captcha_if_needed!
|
||||
render :captcha if captcha_required?
|
||||
end
|
||||
|
||||
def set_confirmation_user!
|
||||
# We need to reimplement looking up the user because
|
||||
# Devise::ConfirmationsController#show looks up and confirms in one
|
||||
# step.
|
||||
confirmation_token = params[:confirmation_token]
|
||||
return if confirmation_token.nil?
|
||||
|
||||
@confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
|
||||
end
|
||||
|
||||
def captcha_user_bypass?
|
||||
return true if @confirmation_user.nil? || @confirmation_user.confirmed?
|
||||
end
|
||||
|
||||
def require_unconfirmed!
|
||||
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
|
||||
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
|
||||
|
|
59
app/controllers/concerns/captcha_concern.rb
Normal file
59
app/controllers/concerns/captcha_concern.rb
Normal file
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module CaptchaConcern
|
||||
extend ActiveSupport::Concern
|
||||
include Hcaptcha::Adapters::ViewMethods
|
||||
|
||||
included do
|
||||
helper_method :render_captcha
|
||||
end
|
||||
|
||||
def captcha_available?
|
||||
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
|
||||
end
|
||||
|
||||
def captcha_enabled?
|
||||
captcha_available? && Setting.captcha_enabled
|
||||
end
|
||||
|
||||
def captcha_user_bypass?
|
||||
false
|
||||
end
|
||||
|
||||
def captcha_required?
|
||||
captcha_enabled? && !captcha_user_bypass?
|
||||
end
|
||||
|
||||
def check_captcha!
|
||||
return true unless captcha_required?
|
||||
|
||||
if verify_hcaptcha
|
||||
true
|
||||
else
|
||||
if block_given?
|
||||
message = flash[:hcaptcha_error]
|
||||
flash.delete(:hcaptcha_error)
|
||||
yield message
|
||||
end
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def extend_csp_for_captcha!
|
||||
policy = request.content_security_policy
|
||||
return unless captcha_required? && policy.present?
|
||||
|
||||
%w(script_src frame_src style_src connect_src).each do |directive|
|
||||
values = policy.send(directive)
|
||||
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
|
||||
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
|
||||
policy.send(directive, *values)
|
||||
end
|
||||
end
|
||||
|
||||
def render_captcha
|
||||
return unless captcha_required?
|
||||
|
||||
hcaptcha_tags
|
||||
end
|
||||
end
|
|
@ -63,7 +63,7 @@ class FollowerAccountsController < ApplicationController
|
|||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
size: @account.followers_count,
|
||||
size: @account.public_followers_count,
|
||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
||||
part_of: account_followers_url(@account),
|
||||
next: next_page_url,
|
||||
|
@ -73,7 +73,7 @@ class FollowerAccountsController < ApplicationController
|
|||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_followers_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.followers_count,
|
||||
size: @account.public_followers_count,
|
||||
first: page_url(1)
|
||||
)
|
||||
end
|
||||
|
|
|
@ -66,7 +66,7 @@ class FollowingAccountsController < ApplicationController
|
|||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
size: @account.public_following_count,
|
||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
||||
part_of: account_following_index_url(@account),
|
||||
next: next_page_url,
|
||||
|
@ -76,7 +76,7 @@ class FollowingAccountsController < ApplicationController
|
|||
ActivityPub::CollectionPresenter.new(
|
||||
id: account_following_index_url(@account),
|
||||
type: :ordered,
|
||||
size: @account.following_count,
|
||||
size: @account.public_following_count,
|
||||
first: page_url(1)
|
||||
)
|
||||
end
|
||||
|
|
|
@ -31,7 +31,7 @@ class StatusesCleanupController < ApplicationController
|
|||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :min_favs, :min_reblogs)
|
||||
params.require(:account_statuses_cleanup_policy).permit(:enabled, :min_status_age, :keep_direct, :keep_pinned, :keep_polls, :keep_media, :keep_self_fav, :keep_self_bookmark, :keep_self_emoji, :min_favs, :min_reblogs, :min_emojis)
|
||||
end
|
||||
|
||||
def set_body_classes
|
||||
|
|
|
@ -30,18 +30,18 @@ module AccountsHelper
|
|||
def account_description(account)
|
||||
prepend_str = [
|
||||
[
|
||||
number_to_human(account.statuses_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.posts', count: account.statuses_count),
|
||||
number_to_human(account.public_statuses_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.posts', count: account.public_statuses_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.following_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.following', count: account.following_count),
|
||||
number_to_human(account.public_following_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.following', count: account.public_following_count),
|
||||
].join(' '),
|
||||
|
||||
[
|
||||
number_to_human(account.followers_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.followers', count: account.followers_count),
|
||||
number_to_human(account.public_followers_count, precision: 3, strip_insignificant_zeros: true),
|
||||
I18n.t('accounts.followers', count: account.public_followers_count),
|
||||
].join(' '),
|
||||
].join(', ')
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Admin::SettingsHelper
|
||||
def captcha_available?
|
||||
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -24,6 +24,7 @@ module ContextHelper
|
|||
emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => 'fedibird:emojiReactions', '@type' => '@id' } },
|
||||
searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => 'fedibird:searchableBy', '@type' => '@id' } },
|
||||
subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => 'kmyblue:subscribableBy', '@type' => '@id' } },
|
||||
other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' },
|
||||
olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
}.freeze
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { LayoutType } from '../is_mobile';
|
||||
|
||||
export const focusApp = createAction('APP_FOCUS');
|
||||
export const unfocusApp = createAction('APP_UNFOCUS');
|
||||
|
||||
type ChangeLayoutPayload = {
|
||||
interface ChangeLayoutPayload {
|
||||
layout: LayoutType;
|
||||
};
|
||||
}
|
||||
export const changeLayout =
|
||||
createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');
|
||||
|
|
|
@ -281,11 +281,6 @@ export function uploadCompose(files) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (getState().getIn(['compose', 'poll'])) {
|
||||
dispatch(showAlert(undefined, messages.uploadErrorPoll));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(uploadComposeRequest());
|
||||
|
||||
for (const [i, file] of Array.from(files).entries()) {
|
||||
|
|
|
@ -91,6 +91,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoiler_text = '';
|
||||
}
|
||||
|
||||
console.dir(normalStatus.emojis);
|
||||
if (normalStatus.emojis && normalStatus.emojis.some((emoji) => emoji.is_sensitive) && !normalStatus.spoiler_text) {
|
||||
normalStatus.spoiler_text = '[Contains sensitive custom emoji(s)]';
|
||||
}
|
||||
|
||||
const spoilerText = normalStatus.spoiler_text || '';
|
||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||
const emojiMap = makeEmojiMap(normalStatus);
|
||||
|
|
|
@ -213,7 +213,8 @@ export function emojiReact(status, emoji) {
|
|||
|
||||
const api_emoji = typeof emoji !== 'string' ? (emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native) : emoji;
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function () {
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function (response) {
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(emojiReactSuccess(status, emoji));
|
||||
}).catch(function (error) {
|
||||
dispatch(emojiReactFail(status, emoji, error));
|
||||
|
@ -225,7 +226,9 @@ export function unEmojiReact(status, emoji) {
|
|||
return (dispatch, getState) => {
|
||||
dispatch(unEmojiReactRequest(status, emoji));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then(() => {
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then((response) => {
|
||||
// TODO: do not update because this api has a bug
|
||||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(unEmojiReactSuccess(status, emoji));
|
||||
}).catch(error => {
|
||||
dispatch(unEmojiReactFail(status, emoji, error));
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import api from '../api';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
|
||||
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
|
||||
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
|
||||
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export function fetchPinnedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchPinnedStatusesRequest());
|
||||
|
|
|
@ -102,6 +102,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
|||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||
break;
|
||||
case 'emoji_reaction':
|
||||
// @ts-expect-error
|
||||
dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me'])));
|
||||
break;
|
||||
case 'conversation':
|
||||
|
|
|
@ -98,9 +98,9 @@ export const decode83 = (str: string) => {
|
|||
};
|
||||
|
||||
export const intToRGB = (int: number) => ({
|
||||
r: Math.max(0, (int >> 16)),
|
||||
r: Math.max(0, int >> 16),
|
||||
g: Math.max(0, (int >> 8) & 255),
|
||||
b: Math.max(0, (int & 255)),
|
||||
b: Math.max(0, int & 255),
|
||||
});
|
||||
|
||||
export const getAverageFromBlurhash = (blurhash: string) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export function compareId (id1: string, id2: string) {
|
||||
export function compareId(id1: string, id2: string) {
|
||||
if (id1 === id2) {
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { fromJS } from 'immutable';
|
||||
import DisplayName from '../display_name';
|
||||
import { DisplayName } from '../display_name';
|
||||
|
||||
describe('<DisplayName />', () => {
|
||||
it('renders display name + account name', () => {
|
||||
|
|
|
@ -2,18 +2,18 @@ import React from 'react';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Avatar } from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import { DisplayName } from './display_name';
|
||||
import { IconButton } from './icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from '../initial_state';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
import Skeleton from 'mastodon/components/skeleton';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { counterRenderer } from 'mastodon/components/common_counter';
|
||||
import ShortNumber from 'mastodon/components/short_number';
|
||||
import classNames from 'classnames';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||
|
||||
const messages = defineMessages({
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
|
@ -78,20 +78,7 @@ class Account extends ImmutablePureComponent {
|
|||
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal, children } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Skeleton width={size} height={size} /></div>
|
||||
|
||||
<div>
|
||||
<DisplayName />
|
||||
<Skeleton width='7ch' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
|
@ -165,7 +152,7 @@ class Account extends ImmutablePureComponent {
|
|||
|
||||
<div>
|
||||
<DisplayName account={account} />
|
||||
{!minimal && <><ShortNumber value={account.get('followers_count')} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining}</>}
|
||||
{!minimal && <><ShortNumber value={account.get('followers_count')} isHide={account.getIn(['other_settings', 'hide_followers_count']) || false} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining}</>}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import ShortNumber from './short_number';
|
||||
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
|
||||
import { reduceMotion } from '../initial_state';
|
||||
|
||||
import ShortNumber from './short_number';
|
||||
|
||||
const obfuscatedCount = (count: number) => {
|
||||
if (count < 0) {
|
||||
return 0;
|
||||
|
@ -13,16 +16,13 @@ const obfuscatedCount = (count: number) => {
|
|||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
value: number;
|
||||
obfuscate?: boolean;
|
||||
}
|
||||
export const AnimatedNumber: React.FC<Props> = ({
|
||||
value,
|
||||
obfuscate,
|
||||
})=> {
|
||||
export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
|
||||
const [previousValue, setPreviousValue] = useState(value);
|
||||
const [direction, setDirection] = useState<1|-1>(1);
|
||||
const [direction, setDirection] = useState<1 | -1>(1);
|
||||
|
||||
if (previousValue !== value) {
|
||||
setPreviousValue(value);
|
||||
|
@ -30,24 +30,49 @@ export const AnimatedNumber: React.FC<Props> = ({
|
|||
}
|
||||
|
||||
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
|
||||
const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]);
|
||||
const willLeave = useCallback(
|
||||
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
|
||||
[direction]
|
||||
);
|
||||
|
||||
if (reduceMotion) {
|
||||
return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />;
|
||||
return obfuscate ? (
|
||||
<>{obfuscatedCount(value)}</>
|
||||
) : (
|
||||
<ShortNumber value={value} />
|
||||
);
|
||||
}
|
||||
|
||||
const styles = [{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
}];
|
||||
const styles = [
|
||||
{
|
||||
key: `${value}`,
|
||||
data: value,
|
||||
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
|
||||
{items => (
|
||||
<TransitionMotion
|
||||
styles={styles}
|
||||
willEnter={willEnter}
|
||||
willLeave={willLeave}
|
||||
>
|
||||
{(items) => (
|
||||
<span className='animated-number'>
|
||||
{items.map(({ key, data, style }) => (
|
||||
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
position: direction * style.y > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${style.y * 100}%)`,
|
||||
}}
|
||||
>
|
||||
{obfuscate ? (
|
||||
obfuscatedCount(data as number)
|
||||
) : (
|
||||
<ShortNumber value={data as number} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -154,7 +154,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
this.input.focus();
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
|
|
|
@ -153,7 +153,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
this.textarea.focus();
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
import { useHovering } from '../../hooks/useHovering';
|
||||
import type { Account } from '../../types/resources';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
account: Account;
|
||||
size: number;
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
animate?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
account,
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import React from 'react';
|
||||
import type { Account } from '../../types/resources';
|
||||
|
||||
import { useHovering } from '../../hooks/useHovering';
|
||||
import type { Account } from '../../types/resources';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
account: Account;
|
||||
friend: Account;
|
||||
size?: number;
|
||||
baseSize?: number;
|
||||
overlaySize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const AvatarOverlay: React.FC<Props> = ({
|
||||
account,
|
||||
|
@ -18,13 +19,19 @@ export const AvatarOverlay: React.FC<Props> = ({
|
|||
baseSize = 36,
|
||||
overlaySize = 24,
|
||||
}) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(autoPlayGif);
|
||||
const accountSrc = hovering ? account?.get('avatar') : account?.get('avatar_static');
|
||||
const friendSrc = hovering ? friend?.get('avatar') : friend?.get('avatar_static');
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } =
|
||||
useHovering(autoPlayGif);
|
||||
const accountSrc = hovering
|
||||
? account?.get('avatar')
|
||||
: account?.get('avatar_static');
|
||||
const friendSrc = hovering
|
||||
? friend?.get('avatar')
|
||||
: friend?.get('avatar_static');
|
||||
|
||||
return (
|
||||
<div
|
||||
className='account__avatar-overlay' style={{ width: size, height: size }}
|
||||
className='account__avatar-overlay'
|
||||
style={{ width: size, height: size }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { decode } from 'blurhash';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
type Props = {
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLCanvasElement> {
|
||||
hash: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
|
||||
children?: never;
|
||||
[key: string]: any;
|
||||
}
|
||||
const Blurhash: React.FC<Props> = ({
|
||||
hash,
|
||||
|
@ -21,6 +21,7 @@ const Blurhash: React.FC<Props> = ({
|
|||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const canvas = canvasRef.current!;
|
||||
|
||||
// eslint-disable-next-line no-self-assign
|
||||
canvas.width = canvas.width; // resets canvas
|
||||
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
export const Check: React.FC = () => (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'>
|
||||
<path fillRule='evenodd' d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z' clipRule='evenodd' />
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 20 20'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import Skeleton from 'mastodon/components/skeleton';
|
||||
|
||||
export default class DisplayName extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
others: ImmutablePropTypes.list,
|
||||
localDomain: PropTypes.string,
|
||||
};
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { others, localDomain } = this.props;
|
||||
|
||||
let displayName, suffix, account;
|
||||
|
||||
if (others && others.size > 1) {
|
||||
displayName = others.take(2).map(a => <bdi key={a.get('id')}><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi>).reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
if (others.size - 2 > 0) {
|
||||
suffix = `+${others.size - 2}`;
|
||||
}
|
||||
} else if ((others && others.size > 0) || this.props.account) {
|
||||
if (others && others.size > 0) {
|
||||
account = others.first();
|
||||
} else {
|
||||
account = this.props.account;
|
||||
}
|
||||
|
||||
let acct = account.get('acct');
|
||||
|
||||
if (acct.indexOf('@') === -1 && localDomain) {
|
||||
acct = `${acct}@${localDomain}`;
|
||||
}
|
||||
|
||||
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
|
||||
suffix = <span className='display-name__account'>@{acct}</span>;
|
||||
} else {
|
||||
displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
|
||||
suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='display-name' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
{displayName} {suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
121
app/javascript/mastodon/components/display_name.tsx
Normal file
121
app/javascript/mastodon/components/display_name.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type { Account } from '../../types/resources';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
import Skeleton from './skeleton';
|
||||
|
||||
interface Props {
|
||||
account?: Account;
|
||||
others?: List<Account>;
|
||||
localDomain?: string;
|
||||
}
|
||||
|
||||
export class DisplayName extends React.PureComponent<Props> {
|
||||
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
|
||||
currentTarget,
|
||||
}) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const originalSrc = emoji.getAttribute('data-original');
|
||||
if (originalSrc != null) emoji.src = originalSrc;
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
|
||||
currentTarget,
|
||||
}) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const staticSrc = emoji.getAttribute('data-static');
|
||||
if (staticSrc != null) emoji.src = staticSrc;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { others, localDomain } = this.props;
|
||||
|
||||
let displayName: React.ReactNode,
|
||||
suffix: React.ReactNode,
|
||||
account: Account | undefined;
|
||||
|
||||
if (others && others.size > 0) {
|
||||
account = others.first();
|
||||
} else if (this.props.account) {
|
||||
account = this.props.account;
|
||||
}
|
||||
|
||||
if (others && others.size > 1) {
|
||||
displayName = others
|
||||
.take(2)
|
||||
.map((a) => (
|
||||
<bdi key={a.get('id')}>
|
||||
<strong
|
||||
className='display-name__html'
|
||||
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
||||
/>
|
||||
</bdi>
|
||||
))
|
||||
.reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
if (others.size - 2 > 0) {
|
||||
suffix = `+${others.size - 2}`;
|
||||
}
|
||||
} else if (account) {
|
||||
let acct = account.get('acct');
|
||||
|
||||
if (acct.indexOf('@') === -1 && localDomain) {
|
||||
acct = `${acct}@${localDomain}`;
|
||||
}
|
||||
|
||||
displayName = (
|
||||
<bdi>
|
||||
<strong
|
||||
className='display-name__html'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: account.get('display_name_html'),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
);
|
||||
suffix = <span className='display-name__account'>@{acct}</span>;
|
||||
} else {
|
||||
displayName = (
|
||||
<bdi>
|
||||
<strong className='display-name__html'>
|
||||
<Skeleton width='10ch' />
|
||||
</strong>
|
||||
</bdi>
|
||||
);
|
||||
suffix = (
|
||||
<span className='display-name__account'>
|
||||
<Skeleton width='7ch' />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className='display-name'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
{displayName} {suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import type { InjectedIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
import { InjectedIntl, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblockDomain: {
|
||||
|
@ -9,11 +12,11 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
domain: string;
|
||||
onUnblockDomain: (domain: string) => void;
|
||||
intl: InjectedIntl;
|
||||
};
|
||||
}
|
||||
const _Domain: React.FC<Props> = ({ domain, onUnblockDomain, intl }) => {
|
||||
const handleDomainUnblock = useCallback(() => {
|
||||
onUnblockDomain(domain);
|
||||
|
|
33
app/javascript/mastodon/components/empty_account.tsx
Normal file
33
app/javascript/mastodon/components/empty_account.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import Skeleton from 'mastodon/components/skeleton';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
minimal?: boolean;
|
||||
}
|
||||
|
||||
export const EmptyAccount: React.FC<Props> = ({
|
||||
size = 46,
|
||||
minimal = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Skeleton width={size} height={size} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DisplayName />
|
||||
<Skeleton width='7ch' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
src: string;
|
||||
key: string;
|
||||
alt?: string;
|
||||
|
@ -17,19 +17,23 @@ export const GIFV: React.FC<Props> = ({
|
|||
width,
|
||||
height,
|
||||
onClick,
|
||||
})=> {
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
|
||||
useCallback(() => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
|
||||
const handleClick: React.MouseEventHandler = useCallback((e) => {
|
||||
if (onClick) {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
}, [onClick]);
|
||||
const handleClick: React.MouseEventHandler = useCallback(
|
||||
(e) => {
|
||||
if (onClick) {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='gifv' style={{ position: 'relative' }}>
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
interface Props extends React.HTMLAttributes<HTMLImageElement> {
|
||||
id: string;
|
||||
className?: string;
|
||||
fixedWidth?: boolean;
|
||||
children?: never;
|
||||
[key: string]: any;
|
||||
}
|
||||
export const Icon: React.FC<Props> = ({ id, className, fixedWidth, ...other }) =>
|
||||
<i className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />;
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
id,
|
||||
className,
|
||||
fixedWidth,
|
||||
...other
|
||||
}) => (
|
||||
<i
|
||||
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Icon } from './icon';
|
||||
import { AnimatedNumber } from './animated_number';
|
||||
|
||||
type Props = {
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { AnimatedNumber } from './animated_number';
|
||||
import { Icon } from './icon';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
|
@ -26,12 +28,11 @@ type Props = {
|
|||
href?: string;
|
||||
ariaHidden: boolean;
|
||||
}
|
||||
type States = {
|
||||
activate: boolean,
|
||||
deactivate: boolean,
|
||||
interface States {
|
||||
activate: boolean;
|
||||
deactivate: boolean;
|
||||
}
|
||||
export class IconButton extends React.PureComponent<Props, States> {
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
|
@ -47,7 +48,7 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
deactivate: false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps: Props) {
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
if (!nextProps.animate) return;
|
||||
|
||||
if (this.props.active && !nextProps.active) {
|
||||
|
@ -57,7 +58,7 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
}
|
||||
}
|
||||
|
||||
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled && this.props.onClick != null) {
|
||||
|
@ -83,7 +84,7 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
|
@ -109,10 +110,7 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
ariaHidden,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
activate,
|
||||
deactivate,
|
||||
} = this.state;
|
||||
const { activate, deactivate } = this.state;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
|
@ -130,7 +128,12 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
|
||||
let contents = (
|
||||
<React.Fragment>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />{' '}
|
||||
{typeof counter !== 'undefined' && (
|
||||
<span className='icon-button__counter'>
|
||||
<AnimatedNumber value={counter} obfuscate={obfuscateCount} />
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
|
@ -162,5 +165,4 @@ export class IconButton extends React.PureComponent<Props, States> {
|
|||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,18 +1,26 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
const formatNumber = (num: number): number | string => num > 40 ? '40+' : num;
|
||||
const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num);
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
id: string;
|
||||
count: number;
|
||||
issueBadge: boolean;
|
||||
className: string;
|
||||
}
|
||||
export const IconWithBadge: React.FC<Props> = ({ id, count, issueBadge, className }) => (
|
||||
export const IconWithBadge: React.FC<Props> = ({
|
||||
id,
|
||||
count,
|
||||
issueBadge,
|
||||
className,
|
||||
}) => (
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id={id} fixedWidth className={className} />
|
||||
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
|
||||
{count > 0 && (
|
||||
<i className='icon-with-badge__badge'>{formatNumber(count)}</i>
|
||||
)}
|
||||
{issueBadge && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
);
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import logo from 'mastodon/../images/logo.svg';
|
||||
|
||||
export const WordmarkLogo = () => (
|
||||
export const WordmarkLogo: React.FC = () => (
|
||||
<svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'>
|
||||
<title>Mastodon</title>
|
||||
<use xlinkHref='#logo-symbol-wordmark' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SymbolLogo = () => (
|
||||
export const SymbolLogo: React.FC = () => (
|
||||
<img src={logo} alt='Mastodon' className='logo logo--icon' />
|
||||
);
|
||||
|
||||
export default WordmarkLogo;
|
|
@ -94,15 +94,21 @@ class Item extends React.PureComponent {
|
|||
height = 50;
|
||||
}
|
||||
|
||||
if (size === 5 || size === 6) {
|
||||
if (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) {
|
||||
height = 33;
|
||||
}
|
||||
if (size === 7 || size === 8) {
|
||||
if (size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) {
|
||||
height = 25;
|
||||
}
|
||||
if ((size === 5 && index === 4) || (size === 7 && index === 6)) {
|
||||
width = 100;
|
||||
}
|
||||
if (size === 9) {
|
||||
width = 33;
|
||||
}
|
||||
if (size === 10 || size === 11 || size === 12 || size === 13 || size === 14 || size === 15 || size === 16) {
|
||||
width = 25;
|
||||
}
|
||||
|
||||
if (attachment.get('description')?.length > 0) {
|
||||
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
|
||||
|
@ -241,7 +247,7 @@ class MediaGallery extends React.PureComponent {
|
|||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
|
||||
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
|
||||
} else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
|
@ -266,7 +272,7 @@ class MediaGallery extends React.PureComponent {
|
|||
};
|
||||
|
||||
handleClick = (index) => {
|
||||
this.props.onOpenMedia(this.props.media, index);
|
||||
this.props.onOpenMedia(this.props.media, index, this.props.lang);
|
||||
};
|
||||
|
||||
handleRef = c => {
|
||||
|
@ -310,7 +316,7 @@ class MediaGallery extends React.PureComponent {
|
|||
style.aspectRatio = '16 / 9';
|
||||
}
|
||||
|
||||
const maxSize = displayMediaExpand ? 8 : 4;
|
||||
const maxSize = displayMediaExpand ? 16 : 4;
|
||||
|
||||
const size = media.take(maxSize).size;
|
||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||
|
@ -337,8 +343,15 @@ class MediaGallery extends React.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';
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('media-gallery', rowClass, columnClass)} style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
|
|
|
@ -57,7 +57,7 @@ export default class ModalRoot extends React.PureComponent {
|
|||
this.history = this.context.router ? this.context.router.history : createBrowserHistory();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!!nextProps.children && !this.props.children) {
|
||||
this.activeElement = document.activeElement;
|
||||
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const NotSignedInIndicator: React.FC = () => (
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
|
||||
<FormattedMessage
|
||||
id='not_signed_in_indicator.not_signed_in'
|
||||
defaultMessage='You need to login to access this resource.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const RadioButton: React.FC<Props> = ({ name, value, checked, onChange, label }) => {
|
||||
export const RadioButton: React.FC<Props> = ({
|
||||
name,
|
||||
value,
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<label className='radio-button'>
|
||||
<input
|
||||
|
|
|
@ -1,23 +1,55 @@
|
|||
import React from 'react';
|
||||
import { injectIntl, defineMessages, InjectedIntl } from 'react-intl';
|
||||
|
||||
import type { InjectedIntl } from 'react-intl';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
today: { id: 'relative_time.today', defaultMessage: 'today' },
|
||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
|
||||
just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
|
||||
just_now_full: {
|
||||
id: 'relative_time.full.just_now',
|
||||
defaultMessage: 'just now',
|
||||
},
|
||||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
|
||||
seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
|
||||
seconds_full: {
|
||||
id: 'relative_time.full.seconds',
|
||||
defaultMessage: '{number, plural, one {# second} other {# seconds}} ago',
|
||||
},
|
||||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
|
||||
minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
|
||||
minutes_full: {
|
||||
id: 'relative_time.full.minutes',
|
||||
defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago',
|
||||
},
|
||||
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
|
||||
hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
|
||||
hours_full: {
|
||||
id: 'relative_time.full.hours',
|
||||
defaultMessage: '{number, plural, one {# hour} other {# hours}} ago',
|
||||
},
|
||||
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
|
||||
days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
|
||||
moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
|
||||
seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
|
||||
minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
|
||||
hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
|
||||
days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
|
||||
days_full: {
|
||||
id: 'relative_time.full.days',
|
||||
defaultMessage: '{number, plural, one {# day} other {# days}} ago',
|
||||
},
|
||||
moments_remaining: {
|
||||
id: 'time_remaining.moments',
|
||||
defaultMessage: 'Moments remaining',
|
||||
},
|
||||
seconds_remaining: {
|
||||
id: 'time_remaining.seconds',
|
||||
defaultMessage: '{number, plural, one {# second} other {# seconds}} left',
|
||||
},
|
||||
minutes_remaining: {
|
||||
id: 'time_remaining.minutes',
|
||||
defaultMessage: '{number, plural, one {# minute} other {# minutes}} left',
|
||||
},
|
||||
hours_remaining: {
|
||||
id: 'time_remaining.hours',
|
||||
defaultMessage: '{number, plural, one {# hour} other {# hours}} left',
|
||||
},
|
||||
days_remaining: {
|
||||
id: 'time_remaining.days',
|
||||
defaultMessage: '{number, plural, one {# day} other {# days}} left',
|
||||
},
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
|
@ -36,8 +68,8 @@ const shortDateFormatOptions = {
|
|||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 1000 * 60;
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const DAY = 1000 * 60 * 60 * 24;
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const MAX_DELAY = 2147483647;
|
||||
|
||||
|
@ -57,20 +89,27 @@ const selectUnits = (delta: number) => {
|
|||
|
||||
const getUnitDelay = (units: string) => {
|
||||
switch (units) {
|
||||
case 'second':
|
||||
return SECOND;
|
||||
case 'minute':
|
||||
return MINUTE;
|
||||
case 'hour':
|
||||
return HOUR;
|
||||
case 'day':
|
||||
return DAY;
|
||||
default:
|
||||
return MAX_DELAY;
|
||||
case 'second':
|
||||
return SECOND;
|
||||
case 'minute':
|
||||
return MINUTE;
|
||||
case 'hour':
|
||||
return HOUR;
|
||||
case 'day':
|
||||
return DAY;
|
||||
default:
|
||||
return MAX_DELAY;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeAgoString = (intl: InjectedIntl, date: Date, now: number, year: number, timeGiven: boolean, short?: boolean) => {
|
||||
export const timeAgoString = (
|
||||
intl: InjectedIntl,
|
||||
date: Date,
|
||||
now: number,
|
||||
year: number,
|
||||
timeGiven: boolean,
|
||||
short?: boolean
|
||||
) => {
|
||||
const delta = now - date.getTime();
|
||||
|
||||
let relativeTime;
|
||||
|
@ -78,27 +117,49 @@ export const timeAgoString = (intl: InjectedIntl, date: Date, now: number, year:
|
|||
if (delta < DAY && !timeGiven) {
|
||||
relativeTime = intl.formatMessage(messages.today);
|
||||
} else if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
|
||||
relativeTime = intl.formatMessage(
|
||||
short ? messages.just_now : messages.just_now_full
|
||||
);
|
||||
} else if (delta < 7 * DAY) {
|
||||
if (delta < MINUTE) {
|
||||
relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
|
||||
relativeTime = intl.formatMessage(
|
||||
short ? messages.seconds : messages.seconds_full,
|
||||
{ number: Math.floor(delta / SECOND) }
|
||||
);
|
||||
} else if (delta < HOUR) {
|
||||
relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
|
||||
relativeTime = intl.formatMessage(
|
||||
short ? messages.minutes : messages.minutes_full,
|
||||
{ number: Math.floor(delta / MINUTE) }
|
||||
);
|
||||
} else if (delta < DAY) {
|
||||
relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
|
||||
relativeTime = intl.formatMessage(
|
||||
short ? messages.hours : messages.hours_full,
|
||||
{ number: Math.floor(delta / HOUR) }
|
||||
);
|
||||
} else {
|
||||
relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
|
||||
relativeTime = intl.formatMessage(
|
||||
short ? messages.days : messages.days_full,
|
||||
{ number: Math.floor(delta / DAY) }
|
||||
);
|
||||
}
|
||||
} else if (date.getFullYear() === year) {
|
||||
relativeTime = intl.formatDate(date, shortDateFormatOptions);
|
||||
} else {
|
||||
relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
|
||||
relativeTime = intl.formatDate(date, {
|
||||
...shortDateFormatOptions,
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
return relativeTime;
|
||||
};
|
||||
|
||||
const timeRemainingString = (intl: InjectedIntl, date: Date, now: number, timeGiven = true) => {
|
||||
const timeRemainingString = (
|
||||
intl: InjectedIntl,
|
||||
date: Date,
|
||||
now: number,
|
||||
timeGiven = true
|
||||
) => {
|
||||
const delta = date.getTime() - now;
|
||||
|
||||
let relativeTime;
|
||||
|
@ -108,96 +169,112 @@ const timeRemainingString = (intl: InjectedIntl, date: Date, now: number, timeGi
|
|||
} else if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(messages.moments_remaining);
|
||||
} else if (delta < MINUTE) {
|
||||
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
|
||||
relativeTime = intl.formatMessage(messages.seconds_remaining, {
|
||||
number: Math.floor(delta / SECOND),
|
||||
});
|
||||
} else if (delta < HOUR) {
|
||||
relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
|
||||
relativeTime = intl.formatMessage(messages.minutes_remaining, {
|
||||
number: Math.floor(delta / MINUTE),
|
||||
});
|
||||
} else if (delta < DAY) {
|
||||
relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
|
||||
relativeTime = intl.formatMessage(messages.hours_remaining, {
|
||||
number: Math.floor(delta / HOUR),
|
||||
});
|
||||
} else {
|
||||
relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
|
||||
relativeTime = intl.formatMessage(messages.days_remaining, {
|
||||
number: Math.floor(delta / DAY),
|
||||
});
|
||||
}
|
||||
|
||||
return relativeTime;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
intl: InjectedIntl;
|
||||
timestamp: string;
|
||||
year: number;
|
||||
futureDate?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
type States = {
|
||||
interface States {
|
||||
now: number;
|
||||
}
|
||||
class RelativeTimestamp extends React.Component<Props, States> {
|
||||
|
||||
state = {
|
||||
now: this.props.intl.now(),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
year: (new Date()).getFullYear(),
|
||||
year: new Date().getFullYear(),
|
||||
short: true,
|
||||
};
|
||||
|
||||
_timer: number | undefined;
|
||||
|
||||
shouldComponentUpdate (nextProps: Props, nextState: States) {
|
||||
shouldComponentUpdate(nextProps: Props, nextState: States) {
|
||||
// As of right now the locale doesn't change without a new page load,
|
||||
// but we might as well check in case that ever changes.
|
||||
return this.props.timestamp !== nextProps.timestamp ||
|
||||
return (
|
||||
this.props.timestamp !== nextProps.timestamp ||
|
||||
this.props.intl.locale !== nextProps.intl.locale ||
|
||||
this.state.now !== nextState.now;
|
||||
this.state.now !== nextState.now
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps: Props) {
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
if (this.props.timestamp !== nextProps.timestamp) {
|
||||
this.setState({ now: this.props.intl.now() });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
componentDidMount() {
|
||||
this._scheduleNextUpdate(this.props, this.state);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillUpdate (nextProps: Props, nextState: States) {
|
||||
UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) {
|
||||
this._scheduleNextUpdate(nextProps, nextState);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
componentWillUnmount() {
|
||||
window.clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
_scheduleNextUpdate (props: Props, state: States) {
|
||||
_scheduleNextUpdate(props: Props, state: States) {
|
||||
window.clearTimeout(this._timer);
|
||||
|
||||
const { timestamp } = props;
|
||||
const delta = (new Date(timestamp)).getTime() - state.now;
|
||||
const unitDelay = getUnitDelay(selectUnits(delta));
|
||||
const unitRemainder = Math.abs(delta % unitDelay);
|
||||
const { timestamp } = props;
|
||||
const delta = new Date(timestamp).getTime() - state.now;
|
||||
const unitDelay = getUnitDelay(selectUnits(delta));
|
||||
const unitRemainder = Math.abs(delta % unitDelay);
|
||||
const updateInterval = 1000 * 10;
|
||||
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
|
||||
const delay =
|
||||
delta < 0
|
||||
? Math.max(updateInterval, unitDelay - unitRemainder)
|
||||
: Math.max(updateInterval, unitRemainder);
|
||||
|
||||
this._timer = window.setTimeout(() => {
|
||||
this.setState({ now: this.props.intl.now() });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
render () {
|
||||
render() {
|
||||
const { timestamp, intl, year, futureDate, short } = this.props;
|
||||
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
const relativeTime = futureDate
|
||||
? timeRemainingString(intl, date, this.state.now, timeGiven)
|
||||
: timeAgoString(intl, date, this.state.now, year, timeGiven, short);
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
<time
|
||||
dateTime={timestamp}
|
||||
title={intl.formatDate(date, dateFormatOptions)}
|
||||
>
|
||||
{relativeTime}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp);
|
||||
|
|
|
@ -7,7 +7,7 @@ import ShortNumber from 'mastodon/components/short_number';
|
|||
import Skeleton from 'mastodon/components/skeleton';
|
||||
import Account from 'mastodon/containers/account_container';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { Image } from 'mastodon/components/image';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -41,7 +41,7 @@ class ServerBanner extends React.PureComponent {
|
|||
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
||||
</div>
|
||||
|
||||
<Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
||||
|
||||
<div className='server-banner__description'>
|
||||
{isLoading ? (
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Blurhash } from './blurhash';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
import { Blurhash } from './blurhash';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
blurhash?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Image: React.FC<Props> = ({ src, srcSet, blurhash, className }) => {
|
||||
export const ServerHeroImage: React.FC<Props> = ({
|
||||
src,
|
||||
srcSet,
|
||||
blurhash,
|
||||
className,
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
|
@ -17,7 +24,10 @@ export const Image: React.FC<Props> = ({ src, srcSet, blurhash, className }) =>
|
|||
}, [setLoaded]);
|
||||
|
||||
return (
|
||||
<div className={classNames('image', { loaded }, className)} role='presentation'>
|
||||
<div
|
||||
className={classNames('image', { loaded }, className)}
|
||||
role='presentation'
|
||||
>
|
||||
{blurhash && <Blurhash hash={blurhash} className='image__preview' />}
|
||||
<img src={src} srcSet={srcSet} alt='' onLoad={handleLoad} />
|
||||
</div>
|
|
@ -27,7 +27,7 @@ import { FormattedMessage, FormattedNumber } from 'react-intl';
|
|||
* @param {ShortNumberProps} param0 Props for the component
|
||||
* @returns {JSX.Element} Rendered number
|
||||
*/
|
||||
function ShortNumber({ value, renderer, children }) {
|
||||
function ShortNumber({ value, isHide, renderer, children }) {
|
||||
const shortNumber = toShortNumber(value);
|
||||
const [, division] = shortNumber;
|
||||
|
||||
|
@ -37,7 +37,7 @@ function ShortNumber({ value, renderer, children }) {
|
|||
|
||||
const customRenderer = children != null ? children : renderer;
|
||||
|
||||
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||
const displayNumber = !isHide ? <ShortNumberCounter value={shortNumber} /> : <span>-</span>;
|
||||
|
||||
return customRenderer != null
|
||||
? customRenderer(displayNumber, pluralReady(value, division))
|
||||
|
@ -46,6 +46,7 @@ function ShortNumber({ value, renderer, children }) {
|
|||
|
||||
ShortNumber.propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
isHide: PropTypes.bool,
|
||||
renderer: PropTypes.func,
|
||||
children: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
import DisplayName from './display_name';
|
||||
import { DisplayName } from './display_name';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusEmojiReactionsBar from './status_emoji_reactions_bar';
|
||||
|
@ -79,6 +79,7 @@ class Status extends ImmutablePureComponent {
|
|||
onEmojiReact: PropTypes.func,
|
||||
onUnEmojiReact: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onReblogForceModal: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
|
@ -199,11 +200,12 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
handleOpenVideo = (options) => {
|
||||
const status = this._properStatus();
|
||||
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
|
||||
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), status.get('language'), options);
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index) => {
|
||||
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
|
||||
const status = this._properStatus();
|
||||
this.props.onOpenMedia(status.get('id'), media, index, status.get('language'));
|
||||
};
|
||||
|
||||
handleHotkeyOpenMedia = e => {
|
||||
|
@ -213,10 +215,11 @@ class Status extends ImmutablePureComponent {
|
|||
e.preventDefault();
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
const lang = status.get('language');
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
|
||||
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
|
||||
} else {
|
||||
onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
|
||||
onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -357,6 +360,16 @@ class Status extends ImmutablePureComponent {
|
|||
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
|
||||
const matchedFilters = status.get('matched_filters');
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
let visibilityIcon = visibilityIconInfo[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,
|
||||
|
@ -389,6 +402,7 @@ class Status extends ImmutablePureComponent {
|
|||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id={visibilityIcon.icon} className='status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' 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>
|
||||
);
|
||||
|
@ -511,15 +525,7 @@ class Status extends ImmutablePureComponent {
|
|||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
|
||||
visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
let emojiReactionsBar = null;
|
||||
if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) {
|
||||
|
|
|
@ -24,11 +24,12 @@ const messages = defineMessages({
|
|||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cancelReblog: { id: 'status.cancel_reblog', defaultMessage: 'Unboost' },
|
||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Emoji Reaction' },
|
||||
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Stamp' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
|
@ -69,6 +70,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onFavourite: PropTypes.func,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onReblogForceModal: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
|
@ -151,6 +153,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleReblogForceModalClick = e => {
|
||||
this.props.onReblogForceModal(this.props.status, e);
|
||||
};
|
||||
|
||||
handleBookmarkClick = () => {
|
||||
this.props.onBookmark(this.props.status);
|
||||
};
|
||||
|
@ -272,6 +278,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
menu.push(null);
|
||||
|
||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancelReblog : messages.reblog), action: this.handleReblogForceModalClick });
|
||||
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
|
||||
|
||||
if (writtenByMe && pinnableStatus) {
|
||||
|
|
|
@ -26,6 +26,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
alwaysPrepend: PropTypes.bool,
|
||||
withCounters: PropTypes.bool,
|
||||
timelineId: PropTypes.string,
|
||||
lastId: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -55,7 +56,8 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
|
||||
const { statusIds, lastId, onLoadMore } = this.props;
|
||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||
}, 300, { leading: true });
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
link: string;
|
||||
};
|
||||
}
|
||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||
<span className='verified-badge'>
|
||||
<Icon id='check' className='verified-badge__mark' />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { store } from '../store/configureStore';
|
||||
import { store } from '../store';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
|
|
|
@ -5,7 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
|
|||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { BrowserRouter, Route } from 'react-router-dom';
|
||||
import { ScrollContext } from 'react-router-scroll-4';
|
||||
import { store } from 'mastodon/store/configureStore';
|
||||
import { store } from 'mastodon/store';
|
||||
import UI from 'mastodon/features/ui';
|
||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||
import { hydrateStore } from 'mastodon/actions/store';
|
||||
|
|
|
@ -29,19 +29,20 @@ export default class MediaContainer extends PureComponent {
|
|||
state = {
|
||||
media: null,
|
||||
index: null,
|
||||
lang: null,
|
||||
time: null,
|
||||
backgroundColor: null,
|
||||
options: null,
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index) => {
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media, index });
|
||||
this.setState({ media, index, lang });
|
||||
};
|
||||
|
||||
handleOpenVideo = (options) => {
|
||||
handleOpenVideo = (lang, options) => {
|
||||
const { components } = this.props;
|
||||
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
||||
const mediaList = fromJS(media);
|
||||
|
@ -49,7 +50,7 @@ export default class MediaContainer extends PureComponent {
|
|||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media: mediaList, options });
|
||||
this.setState({ media: mediaList, lang, options });
|
||||
};
|
||||
|
||||
handleCloseMedia = () => {
|
||||
|
@ -105,6 +106,7 @@ export default class MediaContainer extends PureComponent {
|
|||
<MediaModal
|
||||
media={this.state.media}
|
||||
index={this.state.index || 0}
|
||||
lang={this.state.lang}
|
||||
currentTime={this.state.options?.startTime}
|
||||
autoPlay={this.state.options?.autoPlay}
|
||||
volume={this.state.options?.defaultVolume}
|
||||
|
|
|
@ -110,6 +110,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
}
|
||||
},
|
||||
|
||||
onReblogForceModal (status) {
|
||||
dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
|
@ -192,12 +196,12 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
|||
dispatch(mentionCompose(account, router));
|
||||
},
|
||||
|
||||
onOpenMedia (statusId, media, index) {
|
||||
dispatch(openModal('MEDIA', { statusId, media, index }));
|
||||
onOpenMedia (statusId, media, index, lang) {
|
||||
dispatch(openModal('MEDIA', { statusId, media, index, lang }));
|
||||
},
|
||||
|
||||
onOpenVideo (statusId, media, options) {
|
||||
dispatch(openModal('VIDEO', { statusId, media, options }));
|
||||
onOpenVideo (statusId, media, lang, options) {
|
||||
dispatch(openModal('VIDEO', { statusId, media, lang, options }));
|
||||
},
|
||||
|
||||
onBlock (status) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import Account from 'mastodon/containers/account_container';
|
|||
import Skeleton from 'mastodon/components/skeleton';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { Image } from 'mastodon/components/image';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
|
@ -121,7 +121,7 @@ class About extends React.PureComponent {
|
|||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
<div className='about__header'>
|
||||
<Image blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
|
|
@ -22,7 +22,7 @@ class InlineAlert extends React.PureComponent {
|
|||
|
||||
static TRANSITION_DELAY = 200;
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!this.props.show && nextProps.show) {
|
||||
this.setState({ mountMessage: true });
|
||||
} else if (this.props.show && !nextProps.show) {
|
||||
|
@ -58,11 +58,11 @@ class AccountNote extends ImmutablePureComponent {
|
|||
saved: false,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
this._reset();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
const accountWillChange = !is(this.props.account, nextProps.account);
|
||||
const newState = {};
|
||||
|
||||
|
|
|
@ -451,6 +451,7 @@ class Header extends ImmutablePureComponent {
|
|||
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
||||
<ShortNumber
|
||||
value={account.get('statuses_count')}
|
||||
isHide={account.getIn(['other_settings', 'hide_statuses_count']) || false}
|
||||
renderer={counterRenderer('statuses')}
|
||||
/>
|
||||
</NavLink>
|
||||
|
@ -458,6 +459,7 @@ class Header extends ImmutablePureComponent {
|
|||
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||
<ShortNumber
|
||||
value={account.get('following_count')}
|
||||
isHide={account.getIn(['other_settings', 'hide_following_count']) || false}
|
||||
renderer={counterRenderer('following')}
|
||||
/>
|
||||
</NavLink>
|
||||
|
@ -465,6 +467,7 @@ class Header extends ImmutablePureComponent {
|
|||
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||
<ShortNumber
|
||||
value={account.get('followers_count')}
|
||||
isHide={account.getIn(['other_settings', 'hide_followers_count']) || false}
|
||||
renderer={counterRenderer('followers')}
|
||||
/>
|
||||
</NavLink>
|
||||
|
|
|
@ -136,16 +136,17 @@ class AccountGallery extends ImmutablePureComponent {
|
|||
handleOpenMedia = attachment => {
|
||||
const { dispatch } = this.props;
|
||||
const statusId = attachment.getIn(['status', 'id']);
|
||||
const lang = attachment.getIn(['status', 'language']);
|
||||
|
||||
if (attachment.get('type') === 'video') {
|
||||
dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
|
||||
dispatch(openModal('VIDEO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
|
||||
} else if (attachment.get('type') === 'audio') {
|
||||
dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
|
||||
dispatch(openModal('AUDIO', { media: attachment, statusId, lang, options: { autoPlay: true } }));
|
||||
} else {
|
||||
const media = attachment.getIn(['status', 'media_attachments']);
|
||||
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||
|
||||
dispatch(openModal('MEDIA', { media, index, statusId }));
|
||||
dispatch(openModal('MEDIA', { media, index, statusId, lang }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { AvatarOverlay } from '../../../components/avatar_overlay';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default class MovedNote extends ImmutablePureComponent {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
|
||||
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
|
||||
import StatusList from '../../components/status_list';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import Column from '../ui/components/column';
|
||||
|
@ -14,7 +14,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
|
||||
import LimitedAccountHint from './components/limited_account_hint';
|
||||
import { getAccountHidden } from 'mastodon/selectors';
|
||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||
|
|
|
@ -3,8 +3,8 @@ import { connect } from 'react-redux';
|
|||
import { makeGetAccount } from '../../../selectors';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
|
@ -17,7 +17,6 @@ const makeMapStateToProps = () => {
|
|||
return mapStateToProps;
|
||||
};
|
||||
|
||||
|
||||
class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
|
@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
|
|||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/antennas';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
|
||||
|
|
|
@ -136,7 +136,7 @@ class Audio extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
this.setState({ revealed: nextProps.visible });
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ class Blocks extends ImmutablePureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchBlocks());
|
||||
}
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ class Bookmarks extends ImmutablePureComponent {
|
|||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchBookmarkedStatuses());
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ const messages = defineMessages({
|
|||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
||||
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Emoji Reactions' },
|
||||
emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Stamps' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ class ModifierPickerMenu extends React.PureComponent {
|
|||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.active) {
|
||||
this.attachListeners();
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
|
|
|
@ -214,7 +214,7 @@ class PrivacyDropdown extends React.PureComponent {
|
|||
this.props.onChange(value);
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
const { intl: { formatMessage } } = this.props;
|
||||
|
||||
this.options = [
|
||||
|
|
|
@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import PropTypes from 'prop-types';
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import { IconButton } from '../../../components/icon_button';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'searchability.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'searchability.public.long', defaultMessage: 'Anyone can find' },
|
||||
unlisted_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' },
|
||||
unlisted_long: { id: 'searchability.unlisted.long', defaultMessage: 'Your followers can find' },
|
||||
private_short: { id: 'searchability.private.short', defaultMessage: 'Reactionners' },
|
||||
private_long: { id: 'searchability.private.long', defaultMessage: 'Reacter of this post can find' },
|
||||
direct_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
|
||||
direct_long: { id: 'searchability.direct.long', defaultMessage: 'Nobody can find, but you can' },
|
||||
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' },
|
||||
direct_long: { id: 'searchability.private.long', defaultMessage: 'Reacter of this post can find' },
|
||||
limited_short: { id: 'searchability.direct.short', defaultMessage: 'Self only' },
|
||||
limited_long: { id: 'searchability.direct.long', defaultMessage: 'Nobody can find, but you can' },
|
||||
change_searchability: { id: 'searchability.change', defaultMessage: 'Set status searchability' },
|
||||
});
|
||||
|
||||
|
@ -212,14 +212,14 @@ class SearchabilityDropdown extends React.PureComponent {
|
|||
this.props.onChange(value);
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
const { intl: { formatMessage } } = this.props;
|
||||
|
||||
this.options = [
|
||||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
|
||||
{ icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
|
||||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
|
||||
{ icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_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) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import PollButton from '../components/poll_button';
|
|||
import { addPoll, removePoll } from '../../../actions/compose';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
unavailable: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
|
||||
unavailable: false,
|
||||
active: state.getIn(['compose', 'poll']) !== null,
|
||||
});
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
|
|||
|
||||
const mapStateToProps = state => ({
|
||||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) >= 4 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
|
||||
unavailable: state.getIn(['compose', 'poll']) !== null,
|
||||
unavailable: false,
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||
});
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ const mapStateToProps = state => ({
|
|||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
||||
hashtagWarning: ['public', 'public_unlisted'].indexOf(state.getIn(['compose', 'privacy'])) < 0 && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||
searchabilityWarning: state.getIn(['compose', 'searchability']) === 'direct',
|
||||
searchabilityWarning: state.getIn(['compose', 'searchability']) === 'limited',
|
||||
});
|
||||
|
||||
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, searchabilityWarning }) => {
|
||||
|
|
|
@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
|
|||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import DisplayName from 'mastodon/components/display_name';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Button from 'mastodon/components/button';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
|
|
|
@ -34,7 +34,7 @@ class Blocks extends ImmutablePureComponent {
|
|||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchDomainBlocks());
|
||||
}
|
||||
|
||||
|
|
|
@ -143,6 +143,8 @@ export const buildCustomEmojis = (customEmojis) => {
|
|||
const shortcode = emoji.get('shortcode');
|
||||
const url = autoPlayGif ? emoji.get('url') : emoji.get('static_url');
|
||||
const name = shortcode.replace(':', '');
|
||||
const aliases = emoji.get('aliases');
|
||||
const keywords = aliases ? [name, ...aliases] : [name];
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
|
@ -150,7 +152,7 @@ export const buildCustomEmojis = (customEmojis) => {
|
|||
short_names: [name],
|
||||
text: '',
|
||||
emoticons: [],
|
||||
keywords: [name],
|
||||
keywords,
|
||||
imageUrl: url,
|
||||
custom: true,
|
||||
customCategory: emoji.get('category'),
|
||||
|
|
|
@ -13,7 +13,7 @@ import StatusList from 'mastodon/components/status_list';
|
|||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.emoji_reactions', defaultMessage: 'Emoji Reactions' },
|
||||
heading: { id: 'column.emoji_reactions', defaultMessage: 'Stamps' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
|
|
|
@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { fetchEmojiReactions } from 'mastodon/actions/interactions';
|
||||
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
|
|
|
@ -34,7 +34,7 @@ class Favourites extends ImmutablePureComponent {
|
|||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
UNSAFE_componentWillMount () {
|
||||
this.props.dispatch(fetchFavouritedStatuses());
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue