Merge branch 'kb_development' into kb_migration

This commit is contained in:
KMY 2023-05-23 09:34:36 +09:00
commit 9642305755
315 changed files with 3476 additions and 1385 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
module.exports = {
singleQuote: true
singleQuote: true,
jsxSingleQuote: true
}

View file

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

View file

@ -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
View 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 の戻り値に削除されたスタンプが残っている問題
- 休眠ユーザーにアンテナの投稿が配信される問題

View file

@ -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
View file

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

View file

@ -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
View file

@ -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
[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][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 は動いています。
[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][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:
ドメイン購読において、自分自身のドメインを指定できることも特長のつです。また、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 分後に自動削除されます。秒、分、時、日の種類の指定に対応、数値は桁まで、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 👇
ドメインブロック機能が大幅に拡張され、「制限」「停止」としなくても、細分化された操作を個別にブロックすることができます。これによって、相手サーバーとの交流が完全に遮断されるリスクを減らします。適切に管理されておらず善良なユーザーとスパム/攻撃者が同居するようなサーバーへの対策として有効です。
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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 では項目までですが、kmyblue では8個までに拡張しています。
### 連合から送られてくる投稿の添付画像最大数の拡張
本家 Mastodon では個までですが、kmyblue では8個までに拡張しています。ただし Web クライアントでの表示には、各自ユーザーによる設定が必要です。kmyblue ローカルから投稿できる画像の枚数に変更はありません。
### 検索許可
ユーザーは各投稿に「検索許可」パラメータを付与できます。ここで「公開」が指定された投稿は、誰でも自由に検索機能を用いて検索することができます(全文検索に限る)。検索許可に対応していないクライアントアプリから投稿した時の値は、ユーザーが設定することができます。
これは Fedibird の「検索範囲」機能に対応します。API に互換性はありませんが、ActivityPub 仕様は共通しています。
### トレンドの拡張
本家マストドンでは、センシティブフラグのついた全ての投稿がトレンドに掲載されません。kmyblue はその中でも、「センシティブフラグはついているが、画像が添付されておらず CW 付きでもない」投稿をトレンドに掲載します。
本来このような投稿はトレンドに掲載すべきでありませんが、本家 Mastodon の Web クライアントでは文章だけの投稿のセンシティブフラグを自由に操作できないことを理由とした独自対応となります。
### Sidekiq ヘルスチェック
Sidekiq のヘルスチェックを目的として、30秒に1回ずつ指定された URL に HEAD リクエストを送信します。送信先 URL は、環境変数(または`.env.production`)に`SIDEKIQ_HEALTH_FETCH_URL`として指定します。

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

@ -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(', ')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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());

View file

@ -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':

View file

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

View file

@ -1,4 +1,4 @@
export function compareId (id1: string, id2: string) {
export function compareId(id1: string, id2: string) {
if (id1 === id2) {
return 0;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>
);
}
}

View file

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

View 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>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ? (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@ class Blocks extends ImmutablePureComponent {
multiColumn: PropTypes.bool,
};
componentWillMount () {
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBlocks());
}

View file

@ -34,7 +34,7 @@ class Bookmarks extends ImmutablePureComponent {
isLoading: PropTypes.bool,
};
componentWillMount () {
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkedStatuses());
}

View file

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

View file

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

View file

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

View file

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

View file

@ -214,7 +214,7 @@ class PrivacyDropdown extends React.PureComponent {
this.props.onChange(value);
};
componentWillMount () {
UNSAFE_componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@ class Blocks extends ImmutablePureComponent {
multiColumn: PropTypes.bool,
};
componentWillMount () {
UNSAFE_componentWillMount () {
this.props.dispatch(fetchDomainBlocks());
}

View file

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

View file

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

View file

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

View file

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