Merge tag 'v3.2.0' into hometown-dev
This commit is contained in:
commit
52cb64a183
@ -1,4 +1,3 @@
|
|||||||
https://github.com/heroku/heroku-buildpack-apt
|
https://github.com/heroku/heroku-buildpack-apt
|
||||||
https://github.com/Scalingo/ffmpeg-buildpack
|
https://github.com/Scalingo/ffmpeg-buildpack
|
||||||
https://github.com/Scalingo/nodejs-buildpack
|
|
||||||
https://github.com/Scalingo/ruby-buildpack
|
https://github.com/Scalingo/ruby-buildpack
|
||||||
|
@ -72,11 +72,12 @@ aliases:
|
|||||||
- run:
|
- run:
|
||||||
name: Set bundler settings
|
name: Set bundler settings
|
||||||
command: |
|
command: |
|
||||||
bundle config clean 'true'
|
bundle config --local clean 'true'
|
||||||
bundle config deployment 'true'
|
bundle config --local deployment 'true'
|
||||||
bundle config with 'pam_authentication'
|
bundle config --local with 'pam_authentication'
|
||||||
bundle config without 'development production'
|
bundle config --local without 'development production'
|
||||||
bundle config frozen 'true'
|
bundle config --local frozen 'true'
|
||||||
|
bundle config --local path $BUNDLE_PATH
|
||||||
- run:
|
- run:
|
||||||
name: Install bundler dependencies
|
name: Install bundler dependencies
|
||||||
command: bundle check || (bundle install && bundle clean)
|
command: bundle check || (bundle install && bundle clean)
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
version: 1
|
|
||||||
|
|
||||||
update_configs:
|
|
||||||
- package_manager: "ruby:bundler"
|
|
||||||
directory: "/"
|
|
||||||
update_schedule: "weekly"
|
|
||||||
# Supported update schedule: live daily weekly monthly
|
|
||||||
version_requirement_updates: "auto"
|
|
||||||
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
|
|
||||||
allowed_updates:
|
|
||||||
- match:
|
|
||||||
dependency_type: "all"
|
|
||||||
# Supported dependency types: all indirect direct production development
|
|
||||||
update_type: "all"
|
|
||||||
# Supported update types: all security
|
|
||||||
|
|
||||||
- package_manager: "javascript"
|
|
||||||
directory: "/"
|
|
||||||
update_schedule: "weekly"
|
|
||||||
# Supported update schedule: live daily weekly monthly
|
|
||||||
version_requirement_updates: "auto"
|
|
||||||
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
|
|
||||||
allowed_updates:
|
|
||||||
- match:
|
|
||||||
dependency_type: "all"
|
|
||||||
# Supported dependency types: all indirect direct production development
|
|
||||||
update_type: "all"
|
|
||||||
# Supported update types: all security
|
|
@ -1,265 +1,60 @@
|
|||||||
# Service dependencies
|
# This is a sample configuration file. You can generate your configuration
|
||||||
# You may set REDIS_URL instead for more advanced options
|
# with the `rake mastodon:setup` interactive setup wizard, but to customize
|
||||||
# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers
|
# your setup even further, you'll need to edit it manually. This sample does
|
||||||
REDIS_HOST=redis
|
# not demonstrate all available configuration options. Please look at
|
||||||
REDIS_PORT=6379
|
# https://docs.joinmastodon.org/admin/config/ for the full documentation.
|
||||||
# You may set DATABASE_URL instead for more advanced options
|
|
||||||
DB_HOST=db
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_NAME=postgres
|
|
||||||
DB_PASS=
|
|
||||||
DB_PORT=5432
|
|
||||||
# Optional ElasticSearch configuration
|
|
||||||
# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set)
|
|
||||||
# ES_ENABLED=true
|
|
||||||
# ES_HOST=es
|
|
||||||
# ES_PORT=9200
|
|
||||||
|
|
||||||
# Federation
|
# Federation
|
||||||
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation.
|
# ----------
|
||||||
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
|
# This identifies your server and cannot be changed safely later
|
||||||
|
# ----------
|
||||||
LOCAL_DOMAIN=example.com
|
LOCAL_DOMAIN=example.com
|
||||||
|
|
||||||
# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links)
|
# Redis
|
||||||
|
# -----
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Use this only if you need to run mastodon on a different domain than the one used for federation.
|
# PostgreSQL
|
||||||
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md
|
# ----------
|
||||||
# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
|
DB_HOST=/var/run/postgresql
|
||||||
# WEB_DOMAIN=mastodon.example.com
|
DB_USER=mastodon
|
||||||
|
DB_NAME=mastodon_production
|
||||||
|
DB_PASS=
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
# Use this if you want to have several aliases handler@example1.com
|
# ElasticSearch (optional)
|
||||||
# handler@example2.com etc. for the same user. LOCAL_DOMAIN should not
|
# ------------------------
|
||||||
# be added. Comma separated values
|
ES_ENABLED=true
|
||||||
# ALTERNATE_DOMAINS=example1.com,example2.com
|
ES_HOST=localhost
|
||||||
|
ES_PORT=9200
|
||||||
|
|
||||||
# Application secrets
|
# Secrets
|
||||||
# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose)
|
# -------
|
||||||
|
# Make sure to use `rake secret` to generate secrets
|
||||||
|
# -------
|
||||||
SECRET_KEY_BASE=
|
SECRET_KEY_BASE=
|
||||||
OTP_SECRET=
|
OTP_SECRET=
|
||||||
|
|
||||||
# VAPID keys (used for push notifications
|
# Web Push
|
||||||
# You can generate the keys using the following command (first is the private key, second is the public one)
|
# --------
|
||||||
# You should only generate this once per instance. If you later decide to change it, all push subscription will
|
# Generate with `rake mastodon:webpush:generate_vapid_key`
|
||||||
# be invalidated, requiring the users to access the website again to resubscribe.
|
# --------
|
||||||
#
|
|
||||||
# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key` if you use docker compose)
|
|
||||||
#
|
|
||||||
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
|
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
|
||||||
# Registrations
|
# Sending mail
|
||||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
# ------------
|
||||||
# SINGLE_USER_MODE=true
|
|
||||||
# Prevent registrations with following e-mail domains
|
|
||||||
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
|
|
||||||
# Only allow registrations with the following e-mail domains
|
|
||||||
# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
|
|
||||||
|
|
||||||
# Optionally change default language
|
|
||||||
# DEFAULT_LOCALE=de
|
|
||||||
|
|
||||||
# E-mail configuration
|
|
||||||
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
|
|
||||||
# If you want to use an SMTP server without authentication (e.g local Postfix relay)
|
|
||||||
# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and
|
|
||||||
# *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough).
|
|
||||||
SMTP_SERVER=smtp.mailgun.org
|
SMTP_SERVER=smtp.mailgun.org
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_LOGIN=
|
SMTP_LOGIN=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM_ADDRESS=notifications@example.com
|
SMTP_FROM_ADDRESS=notificatons@example.com
|
||||||
#SMTP_REPLY_TO=
|
|
||||||
#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN
|
|
||||||
#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail
|
|
||||||
#SMTP_AUTH_METHOD=plain
|
|
||||||
#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt
|
|
||||||
#SMTP_OPENSSL_VERIFY_MODE=peer
|
|
||||||
#SMTP_ENABLE_STARTTLS_AUTO=true
|
|
||||||
#SMTP_TLS=true
|
|
||||||
|
|
||||||
# Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files.
|
# File storage (optional)
|
||||||
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
|
# -----------------------
|
||||||
# PAPERCLIP_ROOT_URL=/system
|
S3_ENABLED=true
|
||||||
|
S3_BUCKET=files.example.com
|
||||||
# Optional asset host for multi-server setups
|
AWS_ACCESS_KEY_ID=
|
||||||
# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN
|
AWS_SECRET_ACCESS_KEY=
|
||||||
# if WEB_DOMAIN is not set. For example, the server may have the
|
S3_ALIAS_HOST=files.example.com
|
||||||
# following header field:
|
|
||||||
# Access-Control-Allow-Origin: https://example.com/
|
|
||||||
# CDN_HOST=https://assets.example.com
|
|
||||||
|
|
||||||
# S3 (optional)
|
|
||||||
# The attachment host must allow cross origin request from WEB_DOMAIN or
|
|
||||||
# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
|
|
||||||
# following header field:
|
|
||||||
# Access-Control-Allow-Origin: https://192.168.1.123:9000/
|
|
||||||
# S3_ENABLED=true
|
|
||||||
# S3_BUCKET=
|
|
||||||
# AWS_ACCESS_KEY_ID=
|
|
||||||
# AWS_SECRET_ACCESS_KEY=
|
|
||||||
# S3_REGION=
|
|
||||||
# S3_PROTOCOL=http
|
|
||||||
# S3_HOSTNAME=192.168.1.123:9000
|
|
||||||
|
|
||||||
# S3 (Minio Config (optional) Please check Minio instance for details)
|
|
||||||
# The attachment host must allow cross origin request - see the description
|
|
||||||
# above.
|
|
||||||
# S3_ENABLED=true
|
|
||||||
# S3_BUCKET=
|
|
||||||
# AWS_ACCESS_KEY_ID=
|
|
||||||
# AWS_SECRET_ACCESS_KEY=
|
|
||||||
# S3_REGION=
|
|
||||||
# S3_PROTOCOL=https
|
|
||||||
# S3_HOSTNAME=
|
|
||||||
# S3_ENDPOINT=
|
|
||||||
# S3_SIGNATURE_VERSION=
|
|
||||||
|
|
||||||
# Google Cloud Storage (optional)
|
|
||||||
# Use S3 compatible API. Since GCS does not support Multipart Upload,
|
|
||||||
# increase the value of S3_MULTIPART_THRESHOLD to disable Multipart Upload.
|
|
||||||
# The attachment host must allow cross origin request - see the description
|
|
||||||
# above.
|
|
||||||
# S3_ENABLED=true
|
|
||||||
# AWS_ACCESS_KEY_ID=
|
|
||||||
# AWS_SECRET_ACCESS_KEY=
|
|
||||||
# S3_REGION=
|
|
||||||
# S3_PROTOCOL=https
|
|
||||||
# S3_HOSTNAME=storage.googleapis.com
|
|
||||||
# S3_ENDPOINT=https://storage.googleapis.com
|
|
||||||
# S3_MULTIPART_THRESHOLD=52428801 # 50.megabytes
|
|
||||||
|
|
||||||
# Swift (optional)
|
|
||||||
# The attachment host must allow cross origin request - see the description
|
|
||||||
# above.
|
|
||||||
# SWIFT_ENABLED=true
|
|
||||||
# SWIFT_USERNAME=
|
|
||||||
# For Keystone V3, the value for SWIFT_TENANT should be the project name
|
|
||||||
# SWIFT_TENANT=
|
|
||||||
# SWIFT_PASSWORD=
|
|
||||||
# Some OpenStack V3 providers require PROJECT_ID (optional)
|
|
||||||
# SWIFT_PROJECT_ID=
|
|
||||||
# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid
|
|
||||||
# issues with token rate-limiting during high load.
|
|
||||||
# SWIFT_AUTH_URL=
|
|
||||||
# SWIFT_CONTAINER=
|
|
||||||
# SWIFT_OBJECT_URL=
|
|
||||||
# SWIFT_REGION=
|
|
||||||
# Defaults to 'default'
|
|
||||||
# SWIFT_DOMAIN_NAME=
|
|
||||||
# Defaults to 60 seconds. Set to 0 to disable
|
|
||||||
# SWIFT_CACHE_TTL=
|
|
||||||
|
|
||||||
# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare)
|
|
||||||
# S3_ALIAS_HOST=
|
|
||||||
|
|
||||||
# Streaming API integration
|
|
||||||
# STREAMING_API_BASE_URL=
|
|
||||||
|
|
||||||
# Advanced settings
|
|
||||||
# If you need to use pgBouncer, you need to disable prepared statements:
|
|
||||||
# PREPARED_STATEMENTS=false
|
|
||||||
|
|
||||||
# Cluster number setting for streaming API server.
|
|
||||||
# If you comment out following line, cluster number will be `numOfCpuCores - 1`.
|
|
||||||
STREAMING_CLUSTER_NUM=1
|
|
||||||
|
|
||||||
# Docker mastodon user
|
|
||||||
# If you use Docker, you may want to assign UID/GID manually.
|
|
||||||
# UID=1000
|
|
||||||
# GID=1000
|
|
||||||
|
|
||||||
# Maximum allowed character count
|
|
||||||
# MAX_TOOT_CHARS=500
|
|
||||||
|
|
||||||
# LDAP authentication (optional)
|
|
||||||
# LDAP_ENABLED=true
|
|
||||||
# LDAP_HOST=localhost
|
|
||||||
# LDAP_PORT=389
|
|
||||||
# LDAP_METHOD=simple_tls
|
|
||||||
# LDAP_BASE=
|
|
||||||
# LDAP_BIND_DN=
|
|
||||||
# LDAP_PASSWORD=
|
|
||||||
# LDAP_UID=cn
|
|
||||||
# LDAP_MAIL=mail
|
|
||||||
# LDAP_SEARCH_FILTER=(|(%{uid}=%{email})(%{mail}=%{email}))
|
|
||||||
# LDAP_UID_CONVERSION_ENABLED=true
|
|
||||||
# LDAP_UID_CONVERSION_SEARCH=., -
|
|
||||||
# LDAP_UID_CONVERSION_REPLACE=_
|
|
||||||
|
|
||||||
# PAM authentication (optional)
|
|
||||||
# PAM authentication uses for the email generation the "email" pam variable
|
|
||||||
# and optional as fallback PAM_DEFAULT_SUFFIX
|
|
||||||
# The pam environment variable "email" is provided by:
|
|
||||||
# https://github.com/devkral/pam_email_extractor
|
|
||||||
# PAM_ENABLED=true
|
|
||||||
# Fallback email domain for email address generation (LOCAL_DOMAIN by default)
|
|
||||||
# PAM_EMAIL_DOMAIN=example.com
|
|
||||||
# Name of the pam service (pam "auth" section is evaluated)
|
|
||||||
# PAM_DEFAULT_SERVICE=rpam
|
|
||||||
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default)
|
|
||||||
# PAM_CONTROLLED_SERVICE=rpam
|
|
||||||
|
|
||||||
# Global OAuth settings (optional) :
|
|
||||||
# If you have only one strategy, you may want to enable this
|
|
||||||
# OAUTH_REDIRECT_AT_SIGN_IN=true
|
|
||||||
|
|
||||||
# Optional CAS authentication (cf. omniauth-cas) :
|
|
||||||
# CAS_ENABLED=true
|
|
||||||
# CAS_URL=https://sso.myserver.com/
|
|
||||||
# CAS_HOST=sso.myserver.com/
|
|
||||||
# CAS_PORT=443
|
|
||||||
# CAS_SSL=true
|
|
||||||
# CAS_VALIDATE_URL=
|
|
||||||
# CAS_CALLBACK_URL=
|
|
||||||
# CAS_LOGOUT_URL=
|
|
||||||
# CAS_LOGIN_URL=
|
|
||||||
# CAS_UID_FIELD='user'
|
|
||||||
# CAS_CA_PATH=
|
|
||||||
# CAS_DISABLE_SSL_VERIFICATION=false
|
|
||||||
# CAS_UID_KEY='user'
|
|
||||||
# CAS_NAME_KEY='name'
|
|
||||||
# CAS_EMAIL_KEY='email'
|
|
||||||
# CAS_NICKNAME_KEY='nickname'
|
|
||||||
# CAS_FIRST_NAME_KEY='firstname'
|
|
||||||
# CAS_LAST_NAME_KEY='lastname'
|
|
||||||
# CAS_LOCATION_KEY='location'
|
|
||||||
# CAS_IMAGE_KEY='image'
|
|
||||||
# CAS_PHONE_KEY='phone'
|
|
||||||
|
|
||||||
# Optional SAML authentication (cf. omniauth-saml)
|
|
||||||
# SAML_ENABLED=true
|
|
||||||
# SAML_ACS_URL=http://localhost:3000/auth/auth/saml/callback
|
|
||||||
# SAML_ISSUER=https://example.com
|
|
||||||
# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO
|
|
||||||
# SAML_IDP_CERT=
|
|
||||||
# SAML_IDP_CERT_FINGERPRINT=
|
|
||||||
# SAML_NAME_IDENTIFIER_FORMAT=
|
|
||||||
# SAML_CERT=
|
|
||||||
# SAML_PRIVATE_KEY=
|
|
||||||
# SAML_SECURITY_WANT_ASSERTION_SIGNED=true
|
|
||||||
# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
|
|
||||||
# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241"
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42"
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4"
|
|
||||||
# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
|
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
|
|
||||||
|
|
||||||
# Use HTTP proxy for outgoing request (optional)
|
|
||||||
# http_proxy=http://gateway.local:8118
|
|
||||||
# Access control for hidden service.
|
|
||||||
# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
|
|
||||||
|
|
||||||
# Authorized fetch mode (optional)
|
|
||||||
# Require remote servers to authentify when fetching toots, see
|
|
||||||
# https://docs.joinmastodon.org/admin/config/#authorized_fetch
|
|
||||||
# AUTHORIZED_FETCH=true
|
|
||||||
|
|
||||||
# Whitelist mode (optional)
|
|
||||||
# Only allow federation with whitelisted domains, see
|
|
||||||
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
|
|
||||||
# WHITELIST_MODE=true
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
VAGRANT=true
|
VAGRANT=true
|
||||||
LOCAL_DOMAIN=mastodon.local
|
LOCAL_DOMAIN=mastodon.local
|
||||||
BIND=0.0.0.0
|
BIND=0.0.0.0
|
||||||
|
DB_HOST=/var/run/postgresql/
|
||||||
|
@ -199,6 +199,11 @@ module.exports = {
|
|||||||
'import/no-unresolved': 'error',
|
'import/no-unresolved': 'error',
|
||||||
'import/no-webpack-loader-syntax': 'error',
|
'import/no-webpack-loader-syntax': 'error',
|
||||||
|
|
||||||
'promise/catch-or-return': 'error',
|
'promise/catch-or-return': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
allowFinally: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1,2 +1,3 @@
|
|||||||
patreon: mastodon
|
patreon: mastodon
|
||||||
open_collective: mastodon
|
open_collective: mastodon
|
||||||
|
github: [Gargron]
|
||||||
|
22
.github/dependabot.yml
vendored
Normal file
22
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
allow:
|
||||||
|
- dependency-type: all
|
||||||
|
|
||||||
|
- package-ecosystem: bundler
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
allow:
|
||||||
|
- dependency-type: all
|
27
.gitignore
vendored
27
.gitignore
vendored
@ -17,31 +17,36 @@
|
|||||||
/log/*
|
/log/*
|
||||||
!/log/.keep
|
!/log/.keep
|
||||||
/tmp
|
/tmp
|
||||||
coverage
|
/coverage
|
||||||
public/system
|
/public/system
|
||||||
public/assets
|
/public/assets
|
||||||
public/packs
|
/public/packs
|
||||||
public/packs-test
|
/public/packs-test
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
.env.development
|
.env.development
|
||||||
node_modules/
|
/node_modules/
|
||||||
build/
|
/build/
|
||||||
|
|
||||||
# Ignore Vagrant files
|
# Ignore Vagrant files
|
||||||
.vagrant/
|
.vagrant/
|
||||||
|
|
||||||
# Ignore Capistrano customizations
|
# Ignore Capistrano customizations
|
||||||
config/deploy/*
|
/config/deploy/*
|
||||||
|
|
||||||
# Ignore IDE files
|
# Ignore IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose
|
# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose
|
||||||
postgres
|
/postgres
|
||||||
redis
|
/redis
|
||||||
elasticsearch
|
/elasticsearch
|
||||||
|
|
||||||
|
# ignore Helm lockfile, dependency charts, and local values file
|
||||||
|
/chart/Chart.lock
|
||||||
|
/chart/charts/*.tgz
|
||||||
|
/chart/values.yaml
|
||||||
|
|
||||||
# Ignore Apple files
|
# Ignore Apple files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -28,6 +28,10 @@ Layout/EmptyLineAfterMagicComment:
|
|||||||
Layout/SpaceInsideHashLiteralBraces:
|
Layout/SpaceInsideHashLiteralBraces:
|
||||||
EnforcedStyle: space
|
EnforcedStyle: space
|
||||||
|
|
||||||
|
Lint/UselessAccessModifier:
|
||||||
|
ContextCreatingMethods:
|
||||||
|
- class_methods
|
||||||
|
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
Max: 100
|
Max: 100
|
||||||
|
|
||||||
|
535
AUTHORS.md
535
AUTHORS.md
@ -5,62 +5,66 @@ Mastodon is available on [GitHub](https://github.com/tootsuite/mastodon)
|
|||||||
and provided thanks to the work of the following contributors:
|
and provided thanks to the work of the following contributors:
|
||||||
|
|
||||||
* [Gargron](https://github.com/Gargron)
|
* [Gargron](https://github.com/Gargron)
|
||||||
|
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
|
||||||
* [ThibG](https://github.com/ThibG)
|
* [ThibG](https://github.com/ThibG)
|
||||||
* [ykzts](https://github.com/ykzts)
|
* [ykzts](https://github.com/ykzts)
|
||||||
* [dependabot[bot]](https://github.com/apps/dependabot)
|
* [dependabot[bot]](https://github.com/apps/dependabot)
|
||||||
* [akihikodaki](https://github.com/akihikodaki)
|
* [akihikodaki](https://github.com/akihikodaki)
|
||||||
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
|
|
||||||
* [mjankowski](https://github.com/mjankowski)
|
* [mjankowski](https://github.com/mjankowski)
|
||||||
* [unarist](https://github.com/unarist)
|
* [unarist](https://github.com/unarist)
|
||||||
* [yiskah](https://github.com/yiskah)
|
* [yiskah](https://github.com/yiskah)
|
||||||
* [nolanlawson](https://github.com/nolanlawson)
|
* [nolanlawson](https://github.com/nolanlawson)
|
||||||
* [ysksn](https://github.com/ysksn)
|
|
||||||
* [abcang](https://github.com/abcang)
|
* [abcang](https://github.com/abcang)
|
||||||
|
* [ysksn](https://github.com/ysksn)
|
||||||
|
* [mayaeh](https://github.com/mayaeh)
|
||||||
* [sorin-davidoi](https://github.com/sorin-davidoi)
|
* [sorin-davidoi](https://github.com/sorin-davidoi)
|
||||||
* [lynlynlynx](https://github.com/lynlynlynx)
|
* [lynlynlynx](https://github.com/lynlynlynx)
|
||||||
* [mayaeh](https://github.com/mayaeh)
|
|
||||||
* [m4sk1n](mailto:me@m4sk.in)
|
* [m4sk1n](mailto:me@m4sk.in)
|
||||||
* [Marcin Mikołajczak](mailto:me@m4sk.in)
|
* [Marcin Mikołajczak](mailto:me@m4sk.in)
|
||||||
* [Kjwon15](https://github.com/Kjwon15)
|
* [Kjwon15](https://github.com/Kjwon15)
|
||||||
|
* [noellabo](https://github.com/noellabo)
|
||||||
* [renatolond](https://github.com/renatolond)
|
* [renatolond](https://github.com/renatolond)
|
||||||
* [alpaca-tc](https://github.com/alpaca-tc)
|
* [alpaca-tc](https://github.com/alpaca-tc)
|
||||||
* [jeroenpraat](https://github.com/jeroenpraat)
|
* [jeroenpraat](https://github.com/jeroenpraat)
|
||||||
* [nclm](https://github.com/nclm)
|
* [nclm](https://github.com/nclm)
|
||||||
* [ineffyble](https://github.com/ineffyble)
|
* [ineffyble](https://github.com/ineffyble)
|
||||||
* [mabkenar](https://github.com/mabkenar)
|
* [shleeable](https://github.com/shleeable)
|
||||||
|
* [zunda](https://github.com/zunda)
|
||||||
|
* [Masoud Abkenar](mailto:ampbox@gmail.com)
|
||||||
* [blackle](https://github.com/blackle)
|
* [blackle](https://github.com/blackle)
|
||||||
* [Quent-in](https://github.com/Quent-in)
|
* [Quent-in](https://github.com/Quent-in)
|
||||||
* [JantsoP](https://github.com/JantsoP)
|
* [JantsoP](https://github.com/JantsoP)
|
||||||
* [zunda](https://github.com/zunda)
|
|
||||||
* [nullkal](https://github.com/nullkal)
|
* [nullkal](https://github.com/nullkal)
|
||||||
* [yookoala](https://github.com/yookoala)
|
* [yookoala](https://github.com/yookoala)
|
||||||
|
* [Sasha-Sorokin](https://github.com/Sasha-Sorokin)
|
||||||
* [Aditoo17](https://github.com/Aditoo17)
|
* [Aditoo17](https://github.com/Aditoo17)
|
||||||
* [Quenty31](https://github.com/Quenty31)
|
* [Quenty31](https://github.com/Quenty31)
|
||||||
* [marek-lach](https://github.com/marek-lach)
|
* [marek-lach](https://github.com/marek-lach)
|
||||||
* [shuheiktgw](https://github.com/shuheiktgw)
|
* [shuheiktgw](https://github.com/shuheiktgw)
|
||||||
* [ashfurrow](https://github.com/ashfurrow)
|
* [ashfurrow](https://github.com/ashfurrow)
|
||||||
* [eramdam](https://github.com/eramdam)
|
|
||||||
* [noellabo](https://github.com/noellabo)
|
|
||||||
* [takayamaki](https://github.com/takayamaki)
|
|
||||||
* [danhunsaker](https://github.com/danhunsaker)
|
* [danhunsaker](https://github.com/danhunsaker)
|
||||||
|
* [eramdam](https://github.com/eramdam)
|
||||||
|
* [takayamaki](https://github.com/takayamaki)
|
||||||
|
* [ariasuni](https://github.com/ariasuni)
|
||||||
* [masarakki](https://github.com/masarakki)
|
* [masarakki](https://github.com/masarakki)
|
||||||
* [ticky](https://github.com/ticky)
|
* [ticky](https://github.com/ticky)
|
||||||
* [ThisIsMissEm](https://github.com/ThisIsMissEm)
|
* [ThisIsMissEm](https://github.com/ThisIsMissEm)
|
||||||
|
* [hinaloe](https://github.com/hinaloe)
|
||||||
* [hcmiya](https://github.com/hcmiya)
|
* [hcmiya](https://github.com/hcmiya)
|
||||||
* [stephenburgess8](https://github.com/stephenburgess8)
|
* [stephenburgess8](https://github.com/stephenburgess8)
|
||||||
* [Wonderfall](https://github.com/Wonderfall)
|
* [Wonderfall](mailto:wonderfall@targaryen.house)
|
||||||
* [matteoaquila](https://github.com/matteoaquila)
|
* [matteoaquila](https://github.com/matteoaquila)
|
||||||
* [yukimochi](https://github.com/yukimochi)
|
* [yukimochi](https://github.com/yukimochi)
|
||||||
* [palindromordnilap](https://github.com/palindromordnilap)
|
* [palindromordnilap](https://github.com/palindromordnilap)
|
||||||
* [rkarabut](https://github.com/rkarabut)
|
* [rkarabut](https://github.com/rkarabut)
|
||||||
* [Artoria2e5](https://github.com/Artoria2e5)
|
* [trwnh](https://github.com/trwnh)
|
||||||
* [nightpool](https://github.com/nightpool)
|
* [nightpool](https://github.com/nightpool)
|
||||||
|
* [Artoria2e5](https://github.com/Artoria2e5)
|
||||||
* [marrus-sh](https://github.com/marrus-sh)
|
* [marrus-sh](https://github.com/marrus-sh)
|
||||||
* [hinaloe](https://github.com/hinaloe)
|
|
||||||
* [krainboltgreene](https://github.com/krainboltgreene)
|
* [krainboltgreene](https://github.com/krainboltgreene)
|
||||||
* [pfigel](https://github.com/pfigel)
|
* [pfigel](https://github.com/pfigel)
|
||||||
* [Aldarone](https://github.com/Aldarone)
|
|
||||||
* [BoFFire](https://github.com/BoFFire)
|
* [BoFFire](https://github.com/BoFFire)
|
||||||
|
* [Aldarone](https://github.com/Aldarone)
|
||||||
* [clworld](https://github.com/clworld)
|
* [clworld](https://github.com/clworld)
|
||||||
* [MasterGroosha](https://github.com/MasterGroosha)
|
* [MasterGroosha](https://github.com/MasterGroosha)
|
||||||
* [dracos](https://github.com/dracos)
|
* [dracos](https://github.com/dracos)
|
||||||
@ -68,52 +72,50 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [SerCom_KC](mailto:sercom-kc@users.noreply.github.com)
|
* [SerCom_KC](mailto:sercom-kc@users.noreply.github.com)
|
||||||
* [Sylvhem](https://github.com/Sylvhem)
|
* [Sylvhem](https://github.com/Sylvhem)
|
||||||
* [MitarashiDango](https://github.com/MitarashiDango)
|
* [MitarashiDango](https://github.com/MitarashiDango)
|
||||||
|
* [angristan](https://github.com/angristan)
|
||||||
* [JeanGauthier](https://github.com/JeanGauthier)
|
* [JeanGauthier](https://github.com/JeanGauthier)
|
||||||
* [kschaper](https://github.com/kschaper)
|
* [kschaper](https://github.com/kschaper)
|
||||||
* [beatrix-bitrot](https://github.com/beatrix-bitrot)
|
* [beatrix-bitrot](https://github.com/beatrix-bitrot)
|
||||||
* [angristan](https://github.com/angristan)
|
* [koyuawsmbrtn](https://github.com/koyuawsmbrtn)
|
||||||
|
* [BenLubar](https://github.com/BenLubar)
|
||||||
* [adbelle](https://github.com/adbelle)
|
* [adbelle](https://github.com/adbelle)
|
||||||
* [evanminto](https://github.com/evanminto)
|
* [evanminto](https://github.com/evanminto)
|
||||||
* [MightyPork](https://github.com/MightyPork)
|
* [MightyPork](https://github.com/MightyPork)
|
||||||
* [ashleyhull-versent](mailto:ashley.hull@versent.com.au)
|
* [ashleyhull-versent](https://github.com/ashleyhull-versent)
|
||||||
* [yhirano55](https://github.com/yhirano55)
|
* [yhirano55](https://github.com/yhirano55)
|
||||||
* [rinsuki](https://github.com/rinsuki)
|
* [rinsuki](https://github.com/rinsuki)
|
||||||
|
* [dunn](https://github.com/dunn)
|
||||||
|
* [devkral](https://github.com/devkral)
|
||||||
* [camponez](https://github.com/camponez)
|
* [camponez](https://github.com/camponez)
|
||||||
|
* [hugogameiro](https://github.com/hugogameiro)
|
||||||
* [SerCom_KC](mailto:szescxz@gmail.com)
|
* [SerCom_KC](mailto:szescxz@gmail.com)
|
||||||
* [aschmitz](https://github.com/aschmitz)
|
* [aschmitz](https://github.com/aschmitz)
|
||||||
* [trwnh](https://github.com/trwnh)
|
|
||||||
* [devkral](https://github.com/devkral)
|
|
||||||
* [fpiesche](https://github.com/fpiesche)
|
* [fpiesche](https://github.com/fpiesche)
|
||||||
* [hugogameiro](https://github.com/hugogameiro)
|
|
||||||
* [gandaro](https://github.com/gandaro)
|
* [gandaro](https://github.com/gandaro)
|
||||||
* [johnsudaar](https://github.com/johnsudaar)
|
* [johnsudaar](https://github.com/johnsudaar)
|
||||||
* [ariasuni](https://github.com/ariasuni)
|
|
||||||
* [trebmuh](https://github.com/trebmuh)
|
* [trebmuh](https://github.com/trebmuh)
|
||||||
* [rmhasan](https://github.com/rmhasan)
|
* [rmhasan](https://github.com/rmhasan)
|
||||||
* [kedamaDQ](https://github.com/kedamaDQ)
|
* [kedamaDQ](https://github.com/kedamaDQ)
|
||||||
* [lindwurm](https://github.com/lindwurm)
|
* [lindwurm](https://github.com/lindwurm)
|
||||||
* [victorhck](mailto:victorhck@geeko.site)
|
* [victorhck](mailto:victorhck@geeko.site)
|
||||||
* [voidsatisfaction](https://github.com/voidsatisfaction)
|
* [voidsatisfaction](https://github.com/voidsatisfaction)
|
||||||
* [BenLubar](https://github.com/BenLubar)
|
|
||||||
* [hikari-no-yume](https://github.com/hikari-no-yume)
|
* [hikari-no-yume](https://github.com/hikari-no-yume)
|
||||||
* [seefood](https://github.com/seefood)
|
* [seefood](https://github.com/seefood)
|
||||||
* [jackjennings](https://github.com/jackjennings)
|
* [jackjennings](https://github.com/jackjennings)
|
||||||
* [koyuawsmbrtn](https://github.com/koyuawsmbrtn)
|
* [mfmfuyu](https://github.com/mfmfuyu)
|
||||||
|
* [puckipedia](https://github.com/puckipedia)
|
||||||
* [spla](mailto:spla@mastodont.cat)
|
* [spla](mailto:spla@mastodont.cat)
|
||||||
* [expenses](https://github.com/expenses)
|
|
||||||
* [walf443](https://github.com/walf443)
|
* [walf443](https://github.com/walf443)
|
||||||
* [JoelQ](https://github.com/JoelQ)
|
* [JoelQ](https://github.com/JoelQ)
|
||||||
* [mistydemeo](https://github.com/mistydemeo)
|
* [mistydemeo](https://github.com/mistydemeo)
|
||||||
* [dunn](https://github.com/dunn)
|
* [Ashley](mailto:expenses@airmail.cc)
|
||||||
* [xqus](https://github.com/xqus)
|
* [xqus](https://github.com/xqus)
|
||||||
* [pfm-eyesightjp](https://github.com/pfm-eyesightjp)
|
* [pfm-eyesightjp](https://github.com/pfm-eyesightjp)
|
||||||
* [fakenine](https://github.com/fakenine)
|
* [Samy KACIMI](mailto:samy.kacimi@gmail.com)
|
||||||
* [Shleeble](https://github.com/Shleeble)
|
|
||||||
* [tsuwatch](https://github.com/tsuwatch)
|
* [tsuwatch](https://github.com/tsuwatch)
|
||||||
* [victorhck](https://github.com/victorhck)
|
* [victorhck](https://github.com/victorhck)
|
||||||
* [mkljczk](https://github.com/mkljczk)
|
* [mkljczk](https://github.com/mkljczk)
|
||||||
* [manuelviens](https://github.com/manuelviens)
|
* [manuelviens](https://github.com/manuelviens)
|
||||||
* [puckipedia](https://github.com/puckipedia)
|
|
||||||
* [fvh-P](https://github.com/fvh-P)
|
* [fvh-P](https://github.com/fvh-P)
|
||||||
* [rtucker](https://github.com/rtucker)
|
* [rtucker](https://github.com/rtucker)
|
||||||
* [Anna e só](mailto:contraexemplos@gmail.com)
|
* [Anna e só](mailto:contraexemplos@gmail.com)
|
||||||
@ -123,6 +125,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [diomed](https://github.com/diomed)
|
* [diomed](https://github.com/diomed)
|
||||||
* [Neetshin](mailto:neetshin@neetsh.in)
|
* [Neetshin](mailto:neetshin@neetsh.in)
|
||||||
* [rainyday](https://github.com/rainyday)
|
* [rainyday](https://github.com/rainyday)
|
||||||
|
* [tcitworld](https://github.com/tcitworld)
|
||||||
* [ProgVal](https://github.com/ProgVal)
|
* [ProgVal](https://github.com/ProgVal)
|
||||||
* [valentin2105](https://github.com/valentin2105)
|
* [valentin2105](https://github.com/valentin2105)
|
||||||
* [yuntan](https://github.com/yuntan)
|
* [yuntan](https://github.com/yuntan)
|
||||||
@ -136,44 +139,53 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [TheKinrar](https://github.com/TheKinrar)
|
* [TheKinrar](https://github.com/TheKinrar)
|
||||||
* [AA4ch1](https://github.com/AA4ch1)
|
* [AA4ch1](https://github.com/AA4ch1)
|
||||||
* [alexgleason](https://github.com/alexgleason)
|
* [alexgleason](https://github.com/alexgleason)
|
||||||
|
* [Bèr Kessels](mailto:ber@berk.es)
|
||||||
* [cpytel](https://github.com/cpytel)
|
* [cpytel](https://github.com/cpytel)
|
||||||
* [northerner](https://github.com/northerner)
|
* [northerner](https://github.com/northerner)
|
||||||
* [fhemberger](https://github.com/fhemberger)
|
* [fhemberger](https://github.com/fhemberger)
|
||||||
|
* [Gomasy](https://github.com/Gomasy)
|
||||||
* [greysteil](https://github.com/greysteil)
|
* [greysteil](https://github.com/greysteil)
|
||||||
* [hencatsmith](https://github.com/hencatsmith)
|
* [hencatsmith](https://github.com/hencatsmith)
|
||||||
* [d6rkaiz](https://github.com/d6rkaiz)
|
* [d6rkaiz](https://github.com/d6rkaiz)
|
||||||
* [Reverite](https://github.com/Reverite)
|
* [Reverite](https://github.com/Reverite)
|
||||||
* [JohnD28](https://github.com/JohnD28)
|
* [JohnD28](https://github.com/JohnD28)
|
||||||
* [znz](https://github.com/znz)
|
* [znz](https://github.com/znz)
|
||||||
|
* [saper](https://github.com/saper)
|
||||||
* [Naouak](https://github.com/Naouak)
|
* [Naouak](https://github.com/Naouak)
|
||||||
* [pawelngei](https://github.com/pawelngei)
|
* [pawelngei](https://github.com/pawelngei)
|
||||||
* [reneklacan](https://github.com/reneklacan)
|
* [reneklacan](https://github.com/reneklacan)
|
||||||
* [ekiru](https://github.com/ekiru)
|
* [ekiru](https://github.com/ekiru)
|
||||||
* [tcitworld](https://github.com/tcitworld)
|
|
||||||
* [geta6](https://github.com/geta6)
|
* [geta6](https://github.com/geta6)
|
||||||
* [happycoloredbanana](https://github.com/happycoloredbanana)
|
* [happycoloredbanana](https://github.com/happycoloredbanana)
|
||||||
* [leopku](https://github.com/leopku)
|
* [leopku](https://github.com/leopku)
|
||||||
* [SansPseudoFix](https://github.com/SansPseudoFix)
|
* [SansPseudoFix](https://github.com/SansPseudoFix)
|
||||||
* [salvadorpla](https://github.com/salvadorpla)
|
* [spla](mailto:sp@mastodont.cat)
|
||||||
|
* [tateisu](https://github.com/tateisu)
|
||||||
* [tomfhowe](https://github.com/tomfhowe)
|
* [tomfhowe](https://github.com/tomfhowe)
|
||||||
* [noraworld](https://github.com/noraworld)
|
* [noraworld](https://github.com/noraworld)
|
||||||
|
* [lfuelling](https://github.com/lfuelling)
|
||||||
* [theboss](https://github.com/theboss)
|
* [theboss](https://github.com/theboss)
|
||||||
* [nzws](https://github.com/nzws)
|
* [nzws](https://github.com/nzws)
|
||||||
|
* [duxovni](https://github.com/duxovni)
|
||||||
|
* [smorimoto](https://github.com/smorimoto)
|
||||||
* [178inaba](https://github.com/178inaba)
|
* [178inaba](https://github.com/178inaba)
|
||||||
|
* [acid-chicken](https://github.com/acid-chicken)
|
||||||
* [xgess](https://github.com/xgess)
|
* [xgess](https://github.com/xgess)
|
||||||
* [alyssais](https://github.com/alyssais)
|
* [alyssais](https://github.com/alyssais)
|
||||||
* [aablinov](https://github.com/aablinov)
|
* [aablinov](https://github.com/aablinov)
|
||||||
* [stalker314314](https://github.com/stalker314314)
|
* [stalker314314](https://github.com/stalker314314)
|
||||||
* [cutls](https://github.com/cutls)
|
* [cutls](https://github.com/cutls)
|
||||||
|
* [dariusk](https://github.com/dariusk)
|
||||||
* [huertanix](https://github.com/huertanix)
|
* [huertanix](https://github.com/huertanix)
|
||||||
* [genesixx](https://github.com/genesixx)
|
* [eleboucher](https://github.com/eleboucher)
|
||||||
* [halkeye](https://github.com/halkeye)
|
* [halkeye](https://github.com/halkeye)
|
||||||
|
* [Hanage999](https://github.com/Hanage999)
|
||||||
* [treby](https://github.com/treby)
|
* [treby](https://github.com/treby)
|
||||||
* [jpdevries](https://github.com/jpdevries)
|
* [jpdevries](https://github.com/jpdevries)
|
||||||
* [gdpelican](https://github.com/gdpelican)
|
* [gdpelican](https://github.com/gdpelican)
|
||||||
* [kmichl](https://github.com/kmichl)
|
* [kmichl](https://github.com/kmichl)
|
||||||
* [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name)
|
* [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name)
|
||||||
* [saper](https://github.com/saper)
|
* [panarom](https://github.com/panarom)
|
||||||
* [Dar13](https://github.com/Dar13)
|
* [Dar13](https://github.com/Dar13)
|
||||||
* [nevillepark](https://github.com/nevillepark)
|
* [nevillepark](https://github.com/nevillepark)
|
||||||
* [ornithocoder](https://github.com/ornithocoder)
|
* [ornithocoder](https://github.com/ornithocoder)
|
||||||
@ -181,7 +193,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [pierreozoux](https://github.com/pierreozoux)
|
* [pierreozoux](https://github.com/pierreozoux)
|
||||||
* [qguv](https://github.com/qguv)
|
* [qguv](https://github.com/qguv)
|
||||||
* [Ram Lmn](mailto:ramlmn@users.noreply.github.com)
|
* [Ram Lmn](mailto:ramlmn@users.noreply.github.com)
|
||||||
* [aurelia-sl](https://github.com/aurelia-sl)
|
* [Sascha](mailto:sascha@serenitylabs.cloud)
|
||||||
* [harukasan](https://github.com/harukasan)
|
* [harukasan](https://github.com/harukasan)
|
||||||
* [stamak](https://github.com/stamak)
|
* [stamak](https://github.com/stamak)
|
||||||
* [Technowix](https://github.com/Technowix)
|
* [Technowix](https://github.com/Technowix)
|
||||||
@ -196,9 +208,9 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [chr-1x](https://github.com/chr-1x)
|
* [chr-1x](https://github.com/chr-1x)
|
||||||
* [esetomo](https://github.com/esetomo)
|
* [esetomo](https://github.com/esetomo)
|
||||||
* [foxiehkins](https://github.com/foxiehkins)
|
* [foxiehkins](https://github.com/foxiehkins)
|
||||||
|
* [highemerly](https://github.com/highemerly)
|
||||||
* [hoodie](mailto:hoodiekitten@outlook.com)
|
* [hoodie](mailto:hoodiekitten@outlook.com)
|
||||||
* [luzi82](https://github.com/luzi82)
|
* [luzi82](https://github.com/luzi82)
|
||||||
* [duxovni](https://github.com/duxovni)
|
|
||||||
* [slice](https://github.com/slice)
|
* [slice](https://github.com/slice)
|
||||||
* [tmm576](https://github.com/tmm576)
|
* [tmm576](https://github.com/tmm576)
|
||||||
* [unsmell](mailto:unsmell@users.noreply.github.com)
|
* [unsmell](mailto:unsmell@users.noreply.github.com)
|
||||||
@ -209,13 +221,12 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [AndreLewin](https://github.com/AndreLewin)
|
* [AndreLewin](https://github.com/AndreLewin)
|
||||||
* [0xflotus](https://github.com/0xflotus)
|
* [0xflotus](https://github.com/0xflotus)
|
||||||
* [redtachyons](https://github.com/redtachyons)
|
* [redtachyons](https://github.com/redtachyons)
|
||||||
* [acid-chicken](https://github.com/acid-chicken)
|
|
||||||
* [thurloat](https://github.com/thurloat)
|
* [thurloat](https://github.com/thurloat)
|
||||||
* [aaribaud](https://github.com/aaribaud)
|
* [aaribaud](https://github.com/aaribaud)
|
||||||
* [pointlessone](https://github.com/pointlessone)
|
* [pointlessone](https://github.com/pointlessone)
|
||||||
* [Andrew](mailto:andrewlchronister@gmail.com)
|
* [Andrew](mailto:andrewlchronister@gmail.com)
|
||||||
* [aurelien-reeves](https://github.com/aurelien-reeves)
|
* [aurelien-reeves](https://github.com/aurelien-reeves)
|
||||||
* [AnaGelez](https://github.com/AnaGelez)
|
* [elegaanz](https://github.com/elegaanz)
|
||||||
* [estuans](https://github.com/estuans)
|
* [estuans](https://github.com/estuans)
|
||||||
* [dissolve](https://github.com/dissolve)
|
* [dissolve](https://github.com/dissolve)
|
||||||
* [PurpleBooth](https://github.com/PurpleBooth)
|
* [PurpleBooth](https://github.com/PurpleBooth)
|
||||||
@ -227,16 +238,14 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [muffinista](https://github.com/muffinista)
|
* [muffinista](https://github.com/muffinista)
|
||||||
* [cdutson](https://github.com/cdutson)
|
* [cdutson](https://github.com/cdutson)
|
||||||
* [farlistener](https://github.com/farlistener)
|
* [farlistener](https://github.com/farlistener)
|
||||||
* [dariusk](https://github.com/dariusk)
|
|
||||||
* [DavidLibeau](https://github.com/DavidLibeau)
|
* [DavidLibeau](https://github.com/DavidLibeau)
|
||||||
|
* [dmerejkowsky](https://github.com/dmerejkowsky)
|
||||||
* [ddevault](https://github.com/ddevault)
|
* [ddevault](https://github.com/ddevault)
|
||||||
* [Fjoerfoks](https://github.com/Fjoerfoks)
|
* [Fjoerfoks](https://github.com/Fjoerfoks)
|
||||||
* [fmauNeko](https://github.com/fmauNeko)
|
* [fmauNeko](https://github.com/fmauNeko)
|
||||||
* [gloaec](https://github.com/gloaec)
|
* [gloaec](https://github.com/gloaec)
|
||||||
* [Gomasy](https://github.com/Gomasy)
|
|
||||||
* [unstabler](https://github.com/unstabler)
|
* [unstabler](https://github.com/unstabler)
|
||||||
* [potato4d](https://github.com/potato4d)
|
* [potato4d](https://github.com/potato4d)
|
||||||
* [Hanage999](https://github.com/Hanage999)
|
|
||||||
* [h-izumi](https://github.com/h-izumi)
|
* [h-izumi](https://github.com/h-izumi)
|
||||||
* [ErikXXon](https://github.com/ErikXXon)
|
* [ErikXXon](https://github.com/ErikXXon)
|
||||||
* [ian-kelling](https://github.com/ian-kelling)
|
* [ian-kelling](https://github.com/ian-kelling)
|
||||||
@ -251,13 +260,17 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [tkbky](https://github.com/tkbky)
|
* [tkbky](https://github.com/tkbky)
|
||||||
* [Kaylee](mailto:kaylee@codethat.sucks)
|
* [Kaylee](mailto:kaylee@codethat.sucks)
|
||||||
* [Kazhnuz](https://github.com/Kazhnuz)
|
* [Kazhnuz](https://github.com/Kazhnuz)
|
||||||
|
* [mkody](https://github.com/mkody)
|
||||||
* [connyduck](https://github.com/connyduck)
|
* [connyduck](https://github.com/connyduck)
|
||||||
* [LindseyB](https://github.com/LindseyB)
|
* [LindseyB](https://github.com/LindseyB)
|
||||||
* [Lorenz Diener](mailto:halcyon@icosahedron.website)
|
* [Lorenz Diener](mailto:halcyon@icosahedron.website)
|
||||||
* [alimony](https://github.com/alimony)
|
* [Markus Amalthea Magnuson](mailto:markus.magnuson@gmail.com)
|
||||||
|
* [madmath03](https://github.com/madmath03)
|
||||||
* [mig5](https://github.com/mig5)
|
* [mig5](https://github.com/mig5)
|
||||||
* [moritzheiber](https://github.com/moritzheiber)
|
* [moritzheiber](https://github.com/moritzheiber)
|
||||||
|
* [Nathaniel Suchy](mailto:me@lunorian.is)
|
||||||
* [ndarville](https://github.com/ndarville)
|
* [ndarville](https://github.com/ndarville)
|
||||||
|
* [NimaBoscarino](https://github.com/NimaBoscarino)
|
||||||
* [Abzol](https://github.com/Abzol)
|
* [Abzol](https://github.com/Abzol)
|
||||||
* [PatOnTheBack](https://github.com/PatOnTheBack)
|
* [PatOnTheBack](https://github.com/PatOnTheBack)
|
||||||
* [xPaw](https://github.com/xPaw)
|
* [xPaw](https://github.com/xPaw)
|
||||||
@ -287,16 +300,15 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [amazedkoumei](https://github.com/amazedkoumei)
|
* [amazedkoumei](https://github.com/amazedkoumei)
|
||||||
* [anon5r](https://github.com/anon5r)
|
* [anon5r](https://github.com/anon5r)
|
||||||
* [aus-social](https://github.com/aus-social)
|
* [aus-social](https://github.com/aus-social)
|
||||||
* [imbsky](https://github.com/imbsky)
|
|
||||||
* [bsky](mailto:me@imbsky.net)
|
* [bsky](mailto:me@imbsky.net)
|
||||||
* [codl](https://github.com/codl)
|
* [codl](https://github.com/codl)
|
||||||
* [cpsdqs](https://github.com/cpsdqs)
|
* [cpsdqs](https://github.com/cpsdqs)
|
||||||
* [barzamin](https://github.com/barzamin)
|
* [barzamin](https://github.com/barzamin)
|
||||||
* [fhalna](https://github.com/fhalna)
|
* [fhalna](https://github.com/fhalna)
|
||||||
* [highemerly](https://github.com/highemerly)
|
|
||||||
* [haoyayoi](https://github.com/haoyayoi)
|
* [haoyayoi](https://github.com/haoyayoi)
|
||||||
* [ik11235](https://github.com/ik11235)
|
* [ik11235](https://github.com/ik11235)
|
||||||
* [kawax](https://github.com/kawax)
|
* [kawax](https://github.com/kawax)
|
||||||
|
* [shrft](https://github.com/shrft)
|
||||||
* [007lva](https://github.com/007lva)
|
* [007lva](https://github.com/007lva)
|
||||||
* [mbajur](https://github.com/mbajur)
|
* [mbajur](https://github.com/mbajur)
|
||||||
* [matsurai25](https://github.com/matsurai25)
|
* [matsurai25](https://github.com/matsurai25)
|
||||||
@ -307,15 +319,18 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [pinfort](https://github.com/pinfort)
|
* [pinfort](https://github.com/pinfort)
|
||||||
* [rbaumert](https://github.com/rbaumert)
|
* [rbaumert](https://github.com/rbaumert)
|
||||||
* [rhoio](https://github.com/rhoio)
|
* [rhoio](https://github.com/rhoio)
|
||||||
|
* [sclaire-1](https://github.com/sclaire-1)
|
||||||
|
* [umonaca](https://github.com/umonaca)
|
||||||
* [usagi-f](https://github.com/usagi-f)
|
* [usagi-f](https://github.com/usagi-f)
|
||||||
* [vidarlee](https://github.com/vidarlee)
|
* [vidarlee](https://github.com/vidarlee)
|
||||||
* [vjackson725](https://github.com/vjackson725)
|
* [vjackson725](https://github.com/vjackson725)
|
||||||
* [wxcafe](https://github.com/wxcafe)
|
* [wxcafe](https://github.com/wxcafe)
|
||||||
|
* [Grawl](https://github.com/Grawl)
|
||||||
* [新都心(Neet Shin)](mailto:nucx@dio-vox.com)
|
* [新都心(Neet Shin)](mailto:nucx@dio-vox.com)
|
||||||
* [clarfon](https://github.com/clarfon)
|
* [clarfon](https://github.com/clarfon)
|
||||||
* [cygnan](https://github.com/cygnan)
|
* [cygnan](https://github.com/cygnan)
|
||||||
* [Awea](https://github.com/Awea)
|
* [Awea](https://github.com/Awea)
|
||||||
* [halcy](https://github.com/halcy)
|
* [eai04191](https://github.com/eai04191)
|
||||||
* [8398a7](https://github.com/8398a7)
|
* [8398a7](https://github.com/8398a7)
|
||||||
* [857b](https://github.com/857b)
|
* [857b](https://github.com/857b)
|
||||||
* [insom](https://github.com/insom)
|
* [insom](https://github.com/insom)
|
||||||
@ -332,6 +347,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [a2](https://github.com/a2)
|
* [a2](https://github.com/a2)
|
||||||
* [alfiedotwtf](https://github.com/alfiedotwtf)
|
* [alfiedotwtf](https://github.com/alfiedotwtf)
|
||||||
* [0xa](https://github.com/0xa)
|
* [0xa](https://github.com/0xa)
|
||||||
|
* [ArisuOngaku](https://github.com/ArisuOngaku)
|
||||||
* [virtualpain](https://github.com/virtualpain)
|
* [virtualpain](https://github.com/virtualpain)
|
||||||
* [sapphirus](https://github.com/sapphirus)
|
* [sapphirus](https://github.com/sapphirus)
|
||||||
* [amandavisconti](https://github.com/amandavisconti)
|
* [amandavisconti](https://github.com/amandavisconti)
|
||||||
@ -342,15 +358,22 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [schas002](https://github.com/schas002)
|
* [schas002](https://github.com/schas002)
|
||||||
* [contraexemplo](https://github.com/contraexemplo)
|
* [contraexemplo](https://github.com/contraexemplo)
|
||||||
* [abackstrom](https://github.com/abackstrom)
|
* [abackstrom](https://github.com/abackstrom)
|
||||||
|
* [arielrodrigues](https://github.com/arielrodrigues)
|
||||||
|
* [orlea](https://github.com/orlea)
|
||||||
* [armandfardeau](https://github.com/armandfardeau)
|
* [armandfardeau](https://github.com/armandfardeau)
|
||||||
* [raboof](https://github.com/raboof)
|
* [raboof](https://github.com/raboof)
|
||||||
* [jumbosushi](https://github.com/jumbosushi)
|
* [jumbosushi](https://github.com/jumbosushi)
|
||||||
* [ayumin](https://github.com/ayumin)
|
* [ayumin](https://github.com/ayumin)
|
||||||
* [bzg](https://github.com/bzg)
|
* [bzg](https://github.com/bzg)
|
||||||
* [benediktg](https://github.com/benediktg)
|
* [BastienDurel](https://github.com/BastienDurel)
|
||||||
|
* [li-bei](https://github.com/li-bei)
|
||||||
|
* [Benedikt Geißler](mailto:benedikt@g5r.eu)
|
||||||
|
* [BenisonSebastian](https://github.com/BenisonSebastian)
|
||||||
* [blakebarnett](https://github.com/blakebarnett)
|
* [blakebarnett](https://github.com/blakebarnett)
|
||||||
* [bradj](https://github.com/bradj)
|
* [Brad Janke](mailto:brad.janke@gmail.com)
|
||||||
|
* [bclindner](https://github.com/bclindner)
|
||||||
* [brycied00d](https://github.com/brycied00d)
|
* [brycied00d](https://github.com/brycied00d)
|
||||||
|
* [berkes](https://github.com/berkes)
|
||||||
* [carlosjs23](https://github.com/carlosjs23)
|
* [carlosjs23](https://github.com/carlosjs23)
|
||||||
* [cgxxx](https://github.com/cgxxx)
|
* [cgxxx](https://github.com/cgxxx)
|
||||||
* [kibitan](https://github.com/kibitan)
|
* [kibitan](https://github.com/kibitan)
|
||||||
@ -358,41 +381,48 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [chris-martin](https://github.com/chris-martin)
|
* [chris-martin](https://github.com/chris-martin)
|
||||||
* [DoubleMalt](https://github.com/DoubleMalt)
|
* [DoubleMalt](https://github.com/DoubleMalt)
|
||||||
* [Moosh-be](https://github.com/Moosh-be)
|
* [Moosh-be](https://github.com/Moosh-be)
|
||||||
|
* [cchoi12](https://github.com/cchoi12)
|
||||||
* [Motoma](https://github.com/Motoma)
|
* [Motoma](https://github.com/Motoma)
|
||||||
* [Christopher Kolstad](mailto:christopher.kolstad@finn.no)
|
* [Christopher Kolstad](mailto:christopher.kolstad@finn.no)
|
||||||
* [csu](https://github.com/csu)
|
* [csu](https://github.com/csu)
|
||||||
* [kklleemm](https://github.com/kklleemm)
|
* [kklleemm](https://github.com/kklleemm)
|
||||||
* [colindean](https://github.com/colindean)
|
* [colindean](https://github.com/colindean)
|
||||||
|
* [DeeUnderscore](https://github.com/DeeUnderscore)
|
||||||
* [dachinat](https://github.com/dachinat)
|
* [dachinat](https://github.com/dachinat)
|
||||||
* [multiple-creatures](https://github.com/multiple-creatures)
|
* [shapeshifter-system](https://github.com/shapeshifter-system)
|
||||||
* [watilde](https://github.com/watilde)
|
* [watilde](https://github.com/watilde)
|
||||||
* [daprice](https://github.com/daprice)
|
* [daprice](https://github.com/daprice)
|
||||||
* [da2x](https://github.com/da2x)
|
* [da2x](https://github.com/da2x)
|
||||||
|
* [codesections](https://github.com/codesections)
|
||||||
* [dar5hak](https://github.com/dar5hak)
|
* [dar5hak](https://github.com/dar5hak)
|
||||||
* [kant](https://github.com/kant)
|
* [kant](https://github.com/kant)
|
||||||
* [maxolasersquad](https://github.com/maxolasersquad)
|
* [maxolasersquad](https://github.com/maxolasersquad)
|
||||||
* [singingwolfboy](https://github.com/singingwolfboy)
|
* [singingwolfboy](https://github.com/singingwolfboy)
|
||||||
|
* [caldwell](https://github.com/caldwell)
|
||||||
* [davidcelis](https://github.com/davidcelis)
|
* [davidcelis](https://github.com/davidcelis)
|
||||||
|
* [divergentdave](https://github.com/divergentdave)
|
||||||
* [davefp](https://github.com/davefp)
|
* [davefp](https://github.com/davefp)
|
||||||
* [yipdw](https://github.com/yipdw)
|
* [yipdw](https://github.com/yipdw)
|
||||||
* [debanshuk](https://github.com/debanshuk)
|
* [debanshuk](https://github.com/debanshuk)
|
||||||
|
* [mascali33](https://github.com/mascali33)
|
||||||
* [DerekNonGeneric](https://github.com/DerekNonGeneric)
|
* [DerekNonGeneric](https://github.com/DerekNonGeneric)
|
||||||
* [dblandin](https://github.com/dblandin)
|
* [dblandin](https://github.com/dblandin)
|
||||||
* [Drew Gates](mailto:aranaur@users.noreply.github.com)
|
* [Drew Gates](mailto:aranaur@users.noreply.github.com)
|
||||||
* [dtschust](https://github.com/dtschust)
|
* [dtschust](https://github.com/dtschust)
|
||||||
* [Dryusdan](https://github.com/Dryusdan)
|
* [Dryusdan](https://github.com/Dryusdan)
|
||||||
* [eai04191](https://github.com/eai04191)
|
|
||||||
* [d3vgru](https://github.com/d3vgru)
|
* [d3vgru](https://github.com/d3vgru)
|
||||||
* [Elizafox](https://github.com/Elizafox)
|
* [Elizafox](https://github.com/Elizafox)
|
||||||
* [enewhuis](https://github.com/enewhuis)
|
* [enewhuis](https://github.com/enewhuis)
|
||||||
* [ericblade](https://github.com/ericblade)
|
* [ericblade](https://github.com/ericblade)
|
||||||
* [mikoim](https://github.com/mikoim)
|
* [mikoim](https://github.com/mikoim)
|
||||||
* [espenronnevik](https://github.com/espenronnevik)
|
* [espenronnevik](https://github.com/espenronnevik)
|
||||||
|
* [Expenses](mailto:expenses@airmail.cc)
|
||||||
* [fabianonline](https://github.com/fabianonline)
|
* [fabianonline](https://github.com/fabianonline)
|
||||||
* [Finariel](https://github.com/Finariel)
|
* [Finariel](https://github.com/Finariel)
|
||||||
* [siuying](https://github.com/siuying)
|
* [siuying](https://github.com/siuying)
|
||||||
* [zoc](https://github.com/zoc)
|
* [zoc](https://github.com/zoc)
|
||||||
* [fwenzel](https://github.com/fwenzel)
|
* [fwenzel](https://github.com/fwenzel)
|
||||||
|
* [gabrielrumiranda](https://github.com/gabrielrumiranda)
|
||||||
* [GenbuHase](https://github.com/GenbuHase)
|
* [GenbuHase](https://github.com/GenbuHase)
|
||||||
* [nilsding](https://github.com/nilsding)
|
* [nilsding](https://github.com/nilsding)
|
||||||
* [hattori6789](https://github.com/hattori6789)
|
* [hattori6789](https://github.com/hattori6789)
|
||||||
@ -401,6 +431,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [myfreeweb](https://github.com/myfreeweb)
|
* [myfreeweb](https://github.com/myfreeweb)
|
||||||
* [gfaivre](https://github.com/gfaivre)
|
* [gfaivre](https://github.com/gfaivre)
|
||||||
* [Fiaxhs](https://github.com/Fiaxhs)
|
* [Fiaxhs](https://github.com/Fiaxhs)
|
||||||
|
* [rasjonell](https://github.com/rasjonell)
|
||||||
* [reedcourty](https://github.com/reedcourty)
|
* [reedcourty](https://github.com/reedcourty)
|
||||||
* [anneau](https://github.com/anneau)
|
* [anneau](https://github.com/anneau)
|
||||||
* [lanodan](https://github.com/lanodan)
|
* [lanodan](https://github.com/lanodan)
|
||||||
@ -421,46 +452,49 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [jack-michaud](https://github.com/jack-michaud)
|
* [jack-michaud](https://github.com/jack-michaud)
|
||||||
* [Floppy](https://github.com/Floppy)
|
* [Floppy](https://github.com/Floppy)
|
||||||
* [loomchild](https://github.com/loomchild)
|
* [loomchild](https://github.com/loomchild)
|
||||||
|
* [jglauche](https://github.com/jglauche)
|
||||||
* [jenkr55](https://github.com/jenkr55)
|
* [jenkr55](https://github.com/jenkr55)
|
||||||
* [hyenagirl64](https://github.com/hyenagirl64)
|
* [hyenagirl64](https://github.com/hyenagirl64)
|
||||||
* [press5](https://github.com/press5)
|
* [press5](https://github.com/press5)
|
||||||
* [TrollDecker](https://github.com/TrollDecker)
|
* [TrollDecker](https://github.com/TrollDecker)
|
||||||
* [jmontane](https://github.com/jmontane)
|
* [jmontane](https://github.com/jmontane)
|
||||||
* [jonathanklee](https://github.com/jonathanklee)
|
* [Jonathan Klee](mailto:klee.jonathan@gmail.com)
|
||||||
* [jguerder](https://github.com/jguerder)
|
* [Jordan Guerder](mailto:jguerder@fr.pulseheberg.net)
|
||||||
* [Jehops](https://github.com/Jehops)
|
* [Joseph Mingrone](mailto:jehops@users.noreply.github.com)
|
||||||
* [joshuap](https://github.com/joshuap)
|
* [Joshua Wood](mailto:josh@joshuawood.net)
|
||||||
* [Tiwy57](https://github.com/Tiwy57)
|
* [Julien](mailto:tiwy57@users.noreply.github.com)
|
||||||
* [xuv](https://github.com/xuv)
|
* [Julien Deswaef](mailto:juego@requiem4tv.com)
|
||||||
* [Jnsll](https://github.com/Jnsll)
|
* [June Sallou](mailto:jnsll@users.noreply.github.com)
|
||||||
* [j0k3r](https://github.com/j0k3r)
|
* [Jérémy Benoist](mailto:j0k3r@users.noreply.github.com)
|
||||||
* [KEINOS](https://github.com/KEINOS)
|
* [KEINOS](mailto:github@keinos.com)
|
||||||
* [futoase](https://github.com/futoase)
|
* [Keiji Matsuzaki](mailto:futoase@gmail.com)
|
||||||
* [pot8to](https://github.com/pot8to)
|
* [Kevin Liu](mailto:kevin@potatofrom.space)
|
||||||
* [Kit Redgrave](mailto:qwertyitis@gmail.com)
|
* [Kit Redgrave](mailto:qwertyitis@gmail.com)
|
||||||
* [Knut Erik](mailto:abjectio@users.noreply.github.com)
|
* [Knut Erik](mailto:abjectio@users.noreply.github.com)
|
||||||
* [mkody](https://github.com/mkody)
|
* [Kota Ouchi](mailto:k0ta0uchi@gmail.com)
|
||||||
* [k0ta0uchi](https://github.com/k0ta0uchi)
|
* [Krzysztof Jurewicz](mailto:krzysztof.jurewicz@gmail.com)
|
||||||
* [KrzysiekJ](https://github.com/KrzysiekJ)
|
|
||||||
* [Leo Wzukw](mailto:leowzukw@users.noreply.github.com)
|
* [Leo Wzukw](mailto:leowzukw@users.noreply.github.com)
|
||||||
* [Tak](https://github.com/Tak)
|
* [Leonie](mailto:62470640+bubblineyuri@users.noreply.github.com)
|
||||||
* [cacheflow](https://github.com/cacheflow)
|
* [Levi Bard](mailto:taktaktaktaktaktaktaktaktaktak@gmail.com)
|
||||||
* [ldidry](https://github.com/ldidry)
|
* [Lex Alexander](mailto:l.alexander10@gmail.com)
|
||||||
* [jemus42](https://github.com/jemus42)
|
* [Lorenz Diener](mailto:lorenzd@gmail.com)
|
||||||
* [lfuelling](https://github.com/lfuelling)
|
* [Luc Didry](mailto:ldidry@users.noreply.github.com)
|
||||||
* [Grabacr07](https://github.com/Grabacr07)
|
* [Lukas Burk](mailto:jemus42@users.noreply.github.com)
|
||||||
* [mistermantas](https://github.com/mistermantas)
|
* [Manato Kameya](mailto:grabacr07+github@gmail.com)
|
||||||
* [MareenaKunjachan](https://github.com/MareenaKunjachan)
|
* [Mantas](mailto:mistermantas@users.noreply.github.com)
|
||||||
* [mareklach](https://github.com/mareklach)
|
* [Marcin Mikołajczak](mailto:me@mkljczk.pl)
|
||||||
* [wirehack7](https://github.com/wirehack7)
|
* [Mareena Kunjachan](mailto:mareenakunjachan@gmail.com)
|
||||||
* [martymcguire](https://github.com/martymcguire)
|
* [Marek Lach](mailto:marek.brohatwack.lach@gmail.com)
|
||||||
* [marvinkopf](https://github.com/marvinkopf)
|
* [Markus R](mailto:wirehack7@users.noreply.github.com)
|
||||||
* [otsune](https://github.com/otsune)
|
* [Marty McGuire](mailto:schmartissimo@gmail.com)
|
||||||
* [mbugowski](https://github.com/mbugowski)
|
* [Marvin Kopf](mailto:marvinkopf@posteo.de)
|
||||||
|
* [Masafumi Otsune](mailto:info@otsune.com)
|
||||||
|
* [Matej Ľach](mailto:matejlach@users.noreply.github.com)
|
||||||
|
* [Mateusz Bugowski](mailto:23140767+mbugowski@users.noreply.github.com)
|
||||||
* [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com)
|
* [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com)
|
||||||
* [madmath03](https://github.com/madmath03)
|
* [Mathieu Brunot](mailto:mb.mathieu.brunot@gmail.com)
|
||||||
* [matt-auckland](https://github.com/matt-auckland)
|
* [Matt](mailto:matt-auckland@users.noreply.github.com)
|
||||||
* [webroo](https://github.com/webroo)
|
* [Matt Sweetman](mailto:webroo@gmail.com)
|
||||||
* [Matthias Beyer](mailto:mail@beyermatthias.de)
|
* [Matthias Beyer](mailto:mail@beyermatthias.de)
|
||||||
* [Matthias Jouan](mailto:matthias.jouan@gmail.com)
|
* [Matthias Jouan](mailto:matthias.jouan@gmail.com)
|
||||||
* [Matthieu Paret](mailto:matthieuparet69@gmail.com)
|
* [Matthieu Paret](mailto:matthieuparet69@gmail.com)
|
||||||
@ -512,10 +546,11 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [S.H](mailto:gamelinks007@gmail.com)
|
* [S.H](mailto:gamelinks007@gmail.com)
|
||||||
* [Sadiq Saif](mailto:staticsafe@users.noreply.github.com)
|
* [Sadiq Saif](mailto:staticsafe@users.noreply.github.com)
|
||||||
* [Sam Hewitt](mailto:hewittsamuel@gmail.com)
|
* [Sam Hewitt](mailto:hewittsamuel@gmail.com)
|
||||||
* [Sasha Sorokin](mailto:dafri.nochiterov8@gmail.com)
|
* [Sara Aimée Smiseth](mailto:51710585+sarasmiseth@users.noreply.github.com)
|
||||||
* [Satoshi KOJIMA](mailto:skoji@mac.com)
|
* [Satoshi KOJIMA](mailto:skoji@mac.com)
|
||||||
* [ScienJus](mailto:i@scienjus.com)
|
* [ScienJus](mailto:i@scienjus.com)
|
||||||
* [Scott Larkin](mailto:scott@codeclimate.com)
|
* [Scott Larkin](mailto:scott@codeclimate.com)
|
||||||
|
* [Scott Sweeny](mailto:scott@ssweeny.net)
|
||||||
* [Sebastian Hübner](mailto:imolein@users.noreply.github.com)
|
* [Sebastian Hübner](mailto:imolein@users.noreply.github.com)
|
||||||
* [Sebastian Morr](mailto:sebastian@morr.cc)
|
* [Sebastian Morr](mailto:sebastian@morr.cc)
|
||||||
* [Sergei Č](mailto:noiwex1911@gmail.com)
|
* [Sergei Č](mailto:noiwex1911@gmail.com)
|
||||||
@ -525,10 +560,12 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [Shin Kojima](mailto:shin@kojima.org)
|
* [Shin Kojima](mailto:shin@kojima.org)
|
||||||
* [Shouko Yu](mailto:imshouko@gmail.com)
|
* [Shouko Yu](mailto:imshouko@gmail.com)
|
||||||
* [Sina Mashek](mailto:sina@mashek.xyz)
|
* [Sina Mashek](mailto:sina@mashek.xyz)
|
||||||
|
* [Soft. Dev](mailto:24978+nileshkumar@users.noreply.github.com)
|
||||||
* [Soshi Kato](mailto:mail@sossii.com)
|
* [Soshi Kato](mailto:mail@sossii.com)
|
||||||
* [Spanky](mailto:2788886+spankyworks@users.noreply.github.com)
|
* [Spanky](mailto:2788886+spankyworks@users.noreply.github.com)
|
||||||
* [StefOfficiel](mailto:pichard.stephane@free.fr)
|
* [StefOfficiel](mailto:pichard.stephane@free.fr)
|
||||||
* [Steven Tappert](mailto:admin@dark-it.net)
|
* [Steven Tappert](mailto:admin@dark-it.net)
|
||||||
|
* [Stéphane Guillou](mailto:stephane.guillou@member.fsf.org)
|
||||||
* [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com)
|
* [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com)
|
||||||
* [Sébastien Santoro](mailto:dereckson@espace-win.org)
|
* [Sébastien Santoro](mailto:dereckson@espace-win.org)
|
||||||
* [Tad Thorley](mailto:phaedryx@users.noreply.github.com)
|
* [Tad Thorley](mailto:phaedryx@users.noreply.github.com)
|
||||||
@ -536,7 +573,10 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [Takayuki KUSANO](mailto:github@tkusano.jp)
|
* [Takayuki KUSANO](mailto:github@tkusano.jp)
|
||||||
* [TakesxiSximada](mailto:takesxi.sximada@gmail.com)
|
* [TakesxiSximada](mailto:takesxi.sximada@gmail.com)
|
||||||
* [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com)
|
* [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com)
|
||||||
|
* [Taras Gogol](mailto:taras2358@gmail.com)
|
||||||
|
* [Tdxdxoz](mailto:tdxdxoz@gmail.com)
|
||||||
* [TheInventrix](mailto:theinventrix@users.noreply.github.com)
|
* [TheInventrix](mailto:theinventrix@users.noreply.github.com)
|
||||||
|
* [TheMainOne](mailto:50847364+theevilskeleton@users.noreply.github.com)
|
||||||
* [Thomas Alberola](mailto:thomas@needacoffee.fr)
|
* [Thomas Alberola](mailto:thomas@needacoffee.fr)
|
||||||
* [Toby Deshane](mailto:fortyseven@users.noreply.github.com)
|
* [Toby Deshane](mailto:fortyseven@users.noreply.github.com)
|
||||||
* [Toby Pinder](mailto:gigitrix@gmail.com)
|
* [Toby Pinder](mailto:gigitrix@gmail.com)
|
||||||
@ -563,6 +603,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [Yann Klis](mailto:yann.klis@gmail.com)
|
* [Yann Klis](mailto:yann.klis@gmail.com)
|
||||||
* [Yağızhan](mailto:35808275+yagizhan49@users.noreply.github.com)
|
* [Yağızhan](mailto:35808275+yagizhan49@users.noreply.github.com)
|
||||||
* [Yeechan Lu](mailto:wz.bluesnow@gmail.com)
|
* [Yeechan Lu](mailto:wz.bluesnow@gmail.com)
|
||||||
|
* [Your Name](mailto:lorenzd@gmail.com)
|
||||||
* [Yusuke Abe](mailto:moonset20@gmail.com)
|
* [Yusuke Abe](mailto:moonset20@gmail.com)
|
||||||
* [Zachary Spector](mailto:logicaldash@gmail.com)
|
* [Zachary Spector](mailto:logicaldash@gmail.com)
|
||||||
* [ZiiX](mailto:ziix@users.noreply.github.com)
|
* [ZiiX](mailto:ziix@users.noreply.github.com)
|
||||||
@ -572,6 +613,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [bsky](mailto:git@imbsky.net)
|
* [bsky](mailto:git@imbsky.net)
|
||||||
* [caesarologia](mailto:lopesgemelli.1@gmail.com)
|
* [caesarologia](mailto:lopesgemelli.1@gmail.com)
|
||||||
* [cbayerlein](mailto:c.bayerlein@gmail.com)
|
* [cbayerlein](mailto:c.bayerlein@gmail.com)
|
||||||
|
* [chr v1.x](mailto:chr@cybre.space)
|
||||||
* [chrolis](mailto:chrolis@users.noreply.github.com)
|
* [chrolis](mailto:chrolis@users.noreply.github.com)
|
||||||
* [cormo](mailto:cormorant2+github@gmail.com)
|
* [cormo](mailto:cormorant2+github@gmail.com)
|
||||||
* [d0p1](mailto:dopi-sama@hush.com)
|
* [d0p1](mailto:dopi-sama@hush.com)
|
||||||
@ -582,6 +624,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [fusshi-](mailto:dikky1218@users.noreply.github.com)
|
* [fusshi-](mailto:dikky1218@users.noreply.github.com)
|
||||||
* [gentaro](mailto:gentaroooo@gmail.com)
|
* [gentaro](mailto:gentaroooo@gmail.com)
|
||||||
* [gol-cha](mailto:info@mevo.xyz)
|
* [gol-cha](mailto:info@mevo.xyz)
|
||||||
|
* [guigeekz](mailto:pattusg@gmail.com)
|
||||||
* [hakoai](mailto:hk--76@qa2.so-net.ne.jp)
|
* [hakoai](mailto:hk--76@qa2.so-net.ne.jp)
|
||||||
* [haosbvnker](mailto:github@chaosbunker.com)
|
* [haosbvnker](mailto:github@chaosbunker.com)
|
||||||
* [ichi_i](mailto:51489410+ichi-i@users.noreply.github.com)
|
* [ichi_i](mailto:51489410+ichi-i@users.noreply.github.com)
|
||||||
@ -593,10 +636,11 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [jooops](mailto:joops@autistici.org)
|
* [jooops](mailto:joops@autistici.org)
|
||||||
* [jukper](mailto:jukkaperanto@gmail.com)
|
* [jukper](mailto:jukkaperanto@gmail.com)
|
||||||
* [jumoru](mailto:jumoru@mailbox.org)
|
* [jumoru](mailto:jumoru@mailbox.org)
|
||||||
|
* [kaiyou](mailto:pierre@jaury.eu)
|
||||||
* [karlyeurl](mailto:karl.yeurl@gmail.com)
|
* [karlyeurl](mailto:karl.yeurl@gmail.com)
|
||||||
* [kedama](mailto:32974885+kedamadq@users.noreply.github.com)
|
* [kedama](mailto:32974885+kedamadq@users.noreply.github.com)
|
||||||
* [kodai](mailto:shirafuta.kodai@gmail.com)
|
|
||||||
* [kuro5hin](mailto:rusty@kuro5hin.org)
|
* [kuro5hin](mailto:rusty@kuro5hin.org)
|
||||||
|
* [leo60228](mailto:leo@60228.dev)
|
||||||
* [luzpaz](mailto:luzpaz@users.noreply.github.com)
|
* [luzpaz](mailto:luzpaz@users.noreply.github.com)
|
||||||
* [maxypy](mailto:maxime@mpigou.fr)
|
* [maxypy](mailto:maxime@mpigou.fr)
|
||||||
* [mhe](mailto:mail@marcus-herrmann.com)
|
* [mhe](mailto:mail@marcus-herrmann.com)
|
||||||
@ -607,21 +651,25 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [muan](mailto:muan@github.com)
|
* [muan](mailto:muan@github.com)
|
||||||
* [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com)
|
* [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com)
|
||||||
* [neetshin](mailto:neetshin@neetsh.in)
|
* [neetshin](mailto:neetshin@neetsh.in)
|
||||||
|
* [noiob](mailto:8197071+noiob@users.noreply.github.com)
|
||||||
|
* [notozeki](mailto:notozeki@users.noreply.github.com)
|
||||||
|
* [ntl-purism](mailto:57806346+ntl-purism@users.noreply.github.com)
|
||||||
* [nzws](mailto:git-yuzu@svk.jp)
|
* [nzws](mailto:git-yuzu@svk.jp)
|
||||||
* [rch850](mailto:rich850@gmail.com)
|
* [rch850](mailto:rich850@gmail.com)
|
||||||
* [roikale](mailto:roikale@users.noreply.github.com)
|
* [roikale](mailto:roikale@users.noreply.github.com)
|
||||||
* [rysiekpl](mailto:rysiek@hackerspace.pl)
|
* [rysiekpl](mailto:rysiek@hackerspace.pl)
|
||||||
* [saturday06](mailto:dyob@lunaport.net)
|
* [saturday06](mailto:dyob@lunaport.net)
|
||||||
|
* [scd31](mailto:57571338+scd31@users.noreply.github.com)
|
||||||
* [scriptjunkie](mailto:scriptjunkie@scriptjunkie.us)
|
* [scriptjunkie](mailto:scriptjunkie@scriptjunkie.us)
|
||||||
* [seekr](mailto:mario.drs@gmail.com)
|
* [seekr](mailto:mario.drs@gmail.com)
|
||||||
|
* [sternenseemann](mailto:git@lukasepple.de)
|
||||||
* [sundevour](mailto:31990469+sundevour@users.noreply.github.com)
|
* [sundevour](mailto:31990469+sundevour@users.noreply.github.com)
|
||||||
* [syui](mailto:syui@users.noreply.github.com)
|
* [syui](mailto:syui@users.noreply.github.com)
|
||||||
* [tackeyy](mailto:mailto.takita.yusuke@gmail.com)
|
* [tackeyy](mailto:mailto.takita.yusuke@gmail.com)
|
||||||
* [tateisu](mailto:tateisu@gmail.com)
|
* [taicv](mailto:chuvantai@gmail.com)
|
||||||
* [tmyt](mailto:shigure@refy.net)
|
* [tmyt](mailto:shigure@refy.net)
|
||||||
* [trevDev()](mailto:trev@trevdev.ca)
|
* [trevDev()](mailto:trev@trevdev.ca)
|
||||||
* [tsia](mailto:github@tsia.de)
|
* [tsia](mailto:github@tsia.de)
|
||||||
* [umonaca](mailto:53662960+umonaca@users.noreply.github.com)
|
|
||||||
* [utam0k](mailto:k0ma@utam0k.jp)
|
* [utam0k](mailto:k0ma@utam0k.jp)
|
||||||
* [vpzomtrrfrt](mailto:vpzomtrrfrt@gmail.com)
|
* [vpzomtrrfrt](mailto:vpzomtrrfrt@gmail.com)
|
||||||
* [walfie](mailto:walfington@gmail.com)
|
* [walfie](mailto:walfington@gmail.com)
|
||||||
@ -634,6 +682,7 @@ and provided thanks to the work of the following contributors:
|
|||||||
* [りんすき](mailto:6533808+rinsuki@users.noreply.github.com)
|
* [りんすき](mailto:6533808+rinsuki@users.noreply.github.com)
|
||||||
* [ヨイツの賢狼ホロ | 3rd style](mailto:horo@yoitsu.moe)
|
* [ヨイツの賢狼ホロ | 3rd style](mailto:horo@yoitsu.moe)
|
||||||
* [唐宗勛](mailto:tangzongxun@hotmail.com)
|
* [唐宗勛](mailto:tangzongxun@hotmail.com)
|
||||||
|
* [夕日](mailto:xirikm@gmail.com)
|
||||||
* [猫吸血鬼ディフリス / 猫ロキP](mailto:deflis@gmail.com)
|
* [猫吸血鬼ディフリス / 猫ロキP](mailto:deflis@gmail.com)
|
||||||
* [艮 鮟鱇](mailto:ushitora_anqou@yahoo.co.jp)
|
* [艮 鮟鱇](mailto:ushitora_anqou@yahoo.co.jp)
|
||||||
* [西小倉宏信](mailto:nishiko@mindia.jp)
|
* [西小倉宏信](mailto:nishiko@mindia.jp)
|
||||||
@ -645,122 +694,308 @@ This document is provided for informational purposes only. Since it is only upda
|
|||||||
|
|
||||||
Following people have contributed to translation of Mastodon:
|
Following people have contributed to translation of Mastodon:
|
||||||
|
|
||||||
- Zoltán Gera (*Hungarian*)
|
- ᏦᏁᎢᎵᏫ 😷 (*Spanish, Argentina*)
|
||||||
- Kristijan Tkalec (*Slovenian*)
|
- Sveinn í Felli (*Icelandic*)
|
||||||
|
- taicv (*Vietnamese*)
|
||||||
|
- ButterflyOfFire (*Arabic; French; Kabyle*)
|
||||||
|
- Duy (*Vietnamese*)
|
||||||
- Evert Prants (*Estonian*)
|
- Evert Prants (*Estonian*)
|
||||||
- borys_sh (*Ukrainian*)
|
- Zoltán Gera (*Hungarian*)
|
||||||
- ButterflyOfFire (*Arabic; French*)
|
- Daniele Lira Mereb (*Portuguese, Brazilian*)
|
||||||
- Osoitz (*Basque*)
|
- Kristijan Tkalec (*Slovenian*)
|
||||||
- oɹʇuʞ (*Spanish, Argentina*)
|
- stan ionut (*Romanian*)
|
||||||
- koyu (*German*)
|
|
||||||
- Jeroen (*Dutch*)
|
|
||||||
- Muha Aliss (*Turkish*)
|
|
||||||
- 唐宗勛 (*Chinese Simplified*)
|
|
||||||
- Jeong Arm (*Korean; Esperanto; Japanese*)
|
|
||||||
- Oguz Ersen (*Turkish*)
|
|
||||||
- spla (*Catalan*)
|
|
||||||
- Ramdziana F Y (*Indonesian*)
|
- Ramdziana F Y (*Indonesian*)
|
||||||
- Aditoo17 (*Czech*)
|
- Michal Stanke (*Czech*)
|
||||||
- Xosé M. (*Galician*)
|
- Xosé M. (*Galician; Spanish*)
|
||||||
- Roboron (*Spanish*)
|
- 奈卜拉 (*Chinese Simplified*)
|
||||||
- Alix Rossi (*Corsican; French*)
|
- borys_sh (*Ukrainian*)
|
||||||
- Maya Minatsuki (*Japanese*)
|
- Miguel Mayol (*Spanish; Catalan*)
|
||||||
- Masoud Abkenar (*Persian*)
|
- Besnik_b (*Albanian*)
|
||||||
- Thai Localization (*Thai*)
|
- Thai Localization (*Thai*)
|
||||||
- Marek Ľach (*Slovak; Polish*)
|
- Emanuel Pina (*Portuguese*)
|
||||||
- d5Ziif3K (*Ukrainian*)
|
- Jeong Arm (*Korean; Esperanto; Japanese*)
|
||||||
|
- Imre Kristoffer Eilertsen (*Norwegian*)
|
||||||
|
- Danial Behzadi (*Persian*)
|
||||||
|
- Osoitz (*Basque*)
|
||||||
|
- Peterandre (*Norwegian Nynorsk; Norwegian*)
|
||||||
|
- Jeroen (*Dutch*)
|
||||||
|
- spla (*Catalan; Spanish*)
|
||||||
|
- Iváns (*Galician*)
|
||||||
|
- koyu (*German*)
|
||||||
|
- Sasha Sorokin (*Russian; Vietnamese; Swedish; Catalan; Greek; Hungarian; Armenian; Albanian; Galician; French; Danish; German; Korean; Ukrainian*)
|
||||||
|
- enolp (*Asturian*)
|
||||||
|
- Masoud Abkenar (*Persian*)
|
||||||
- lamnatos (*Greek*)
|
- lamnatos (*Greek*)
|
||||||
- Emyn Nant Nefydd (*Welsh*)
|
- Alix Rossi (*Corsican; French*)
|
||||||
|
- arshat (*Kazakh*)
|
||||||
|
- FédiQuébec (*French*)
|
||||||
|
- Marek Ľach (*Slovak; Polish*)
|
||||||
|
- Muha Aliss (*Turkish*)
|
||||||
|
- tolstoevsky (*Russian*)
|
||||||
|
- Emyn-Russell Nt Nefydd (*Welsh*)
|
||||||
|
- Aditoo17 (*Czech*)
|
||||||
|
- Maya Minatsuki (*Japanese*)
|
||||||
|
- ariasuni (*French; Esperanto*)
|
||||||
|
- Roboron (*Spanish*)
|
||||||
|
- Alessandro Levati (*Italian*)
|
||||||
- Diluns (*Occitan*)
|
- Diluns (*Occitan*)
|
||||||
|
- regulartranslator (*Portuguese, Brazilian*)
|
||||||
|
- vishnuvaratharajan (*Tamil*)
|
||||||
|
- Marcin Mikołajczak (*Polish*)
|
||||||
|
- Yi-Jyun Pan (*Chinese Traditional*)
|
||||||
|
- adrmzz (*Sardinian*)
|
||||||
|
- d5Ziif3K (*Ukrainian*)
|
||||||
|
- GiorgioHerbie (*Italian*)
|
||||||
|
- christalleras (*Norwegian Nynorsk*)
|
||||||
|
- Taloran (*Norwegian Nynorsk*)
|
||||||
|
- ThibG (*French; Icelandic*)
|
||||||
|
- Akarshan Biswas (*Bengali*)
|
||||||
- atarashiako (*Chinese Simplified*)
|
- atarashiako (*Chinese Simplified*)
|
||||||
- 101010 (*Polish*)
|
- 101010 (*Polish*)
|
||||||
- Yi-Jyun Pan (*Chinese Traditional*)
|
|
||||||
- silkevicious (*Italian*)
|
- silkevicious (*Italian*)
|
||||||
- FédiQuébec (*French*)
|
- Bertil Hedkvist (*Swedish*)
|
||||||
- Jaz-Michael King (*Welsh*)
|
- cybergene (*Japanese*)
|
||||||
- christalleras (*Norwegian Nynorsk*)
|
- norayr (*Armenian*)
|
||||||
- tykayn (*French*)
|
- William(ѕ)ⁿ (*Spanish*)
|
||||||
- Alessandro Levati (*Italian*)
|
|
||||||
- carolinagiorno (*Portuguese, Brazilian*)
|
|
||||||
- taoxvx (*Danish*)
|
|
||||||
- sabri (*Spanish*)
|
|
||||||
- Sasha Sorokin (*Russian*)
|
|
||||||
- shioko (*Chinese Simplified*)
|
|
||||||
- Evgeny Petrov (*Russian*)
|
|
||||||
- ariasuni (*French; Esperanto*)
|
|
||||||
- Tiago Epifânio (*Portuguese*)
|
- Tiago Epifânio (*Portuguese*)
|
||||||
- dxwc (*Bengali*)
|
- Mentor Gashi (*Albanian*)
|
||||||
|
- Jaz-Michael King (*Welsh*)
|
||||||
|
- carolinagiorno (*Portuguese, Brazilian*)
|
||||||
|
- Roby Thomas (*Malayalam*)
|
||||||
|
- Bharat Kumar (*Hindi*)
|
||||||
|
- tykayn (*French*)
|
||||||
|
- axi (*Finnish*)
|
||||||
|
- Selyan Slimane AMIRI (*Kabyle*)
|
||||||
|
- taoxvx (*Danish*)
|
||||||
|
- Hrach Mkrtchyan (*Armenian*)
|
||||||
|
- sabri (*Spanish; Spanish, Argentina*)
|
||||||
|
- Dewi (*Breton; French*)
|
||||||
|
- SteinarK (*Norwegian Nynorsk*)
|
||||||
|
- Mathias B. Vagnes (*Norwegian*)
|
||||||
|
- dashersyed (*Urdu*)
|
||||||
|
- ThonyVezbe (*Breton*)
|
||||||
|
- Acolyte (*Ukrainian*)
|
||||||
|
- Conight Wang (*Chinese Simplified*)
|
||||||
|
- Damjan Dimitrioski (*Macedonian*)
|
||||||
|
- PPNplus (*Thai*)
|
||||||
|
- Tagomago (*Spanish; French*)
|
||||||
|
- shioko (*Chinese Simplified*)
|
||||||
|
- Balázs Meskó (*Hungarian*)
|
||||||
|
- Evgeny Petrov (*Russian*)
|
||||||
|
- Gwenn (*Breton*)
|
||||||
|
- Ryo (*Korean*)
|
||||||
|
- Rafael H L Moretti (*Portuguese, Brazilian*)
|
||||||
|
- jaranta (*Finnish*)
|
||||||
|
- gagik_ (*Armenian*)
|
||||||
|
- Felicia (*Swedish*)
|
||||||
|
- Jess Rafn (*Danish*)
|
||||||
|
- Stasiek Michalski (*Polish*)
|
||||||
- liffon (*Swedish*)
|
- liffon (*Swedish*)
|
||||||
|
- dxwc (*Bengali*)
|
||||||
|
- Saederup92 (*Danish*)
|
||||||
- Vanege (*Esperanto*)
|
- Vanege (*Esperanto*)
|
||||||
|
- jmontane (*Catalan*)
|
||||||
- Johan Schiff (*Swedish*)
|
- Johan Schiff (*Swedish*)
|
||||||
|
- Arunmozhi (*Tamil*)
|
||||||
- kat (*Ukrainian; Russian*)
|
- kat (*Ukrainian; Russian*)
|
||||||
|
- Laura (*Polish*)
|
||||||
- oti4500 (*Hungarian; Ukrainian*)
|
- oti4500 (*Hungarian; Ukrainian*)
|
||||||
|
- diazepan (*Spanish; Spanish, Argentina*)
|
||||||
|
- Sokratis Alichanidis (*Greek*)
|
||||||
|
- Rikard Linde (*Swedish*)
|
||||||
- Juan José Salvador Piedra (*Spanish*)
|
- Juan José Salvador Piedra (*Spanish*)
|
||||||
- diazepan (*Spanish*)
|
- marzuquccen (*Kabyle*)
|
||||||
|
- BurekzFinezt (*Serbian*)
|
||||||
- SHeija (*Finnish*)
|
- SHeija (*Finnish*)
|
||||||
- Jack R (*Spanish*)
|
- Jack R (*Spanish*)
|
||||||
- Saederup92 (*Danish*)
|
- andruhov (*Ukrainian; Russian*)
|
||||||
- Stasiek Michalski (*Polish*)
|
- 森の子リスのミーコの大冒険 (*Japanese*)
|
||||||
- Dewi (*Breton; French*)
|
- るいーね (*Japanese*)
|
||||||
- cybergene (*Japanese*)
|
- Sam Tux (*Bengali*)
|
||||||
- AW Unad (*Indonesian*)
|
|
||||||
- Andrea Lo Iacono (*Italian*)
|
|
||||||
- Ray (*Spanish*)
|
|
||||||
- Unmual (*Spanish*)
|
- Unmual (*Spanish*)
|
||||||
- Ryo (*Korean*)
|
- AW Unad (*Indonesian*)
|
||||||
|
- Cutls (*Japanese*)
|
||||||
|
- Ray (*Spanish*)
|
||||||
|
- Falling Snowdin (*Vietnamese*)
|
||||||
|
- Andrea Lo Iacono (*Italian*)
|
||||||
|
- EPEMA (*German*)
|
||||||
|
- Kinshuk Sunil (*Hindi*)
|
||||||
|
- Ullas Joseph (*Malayalam*)
|
||||||
|
- Yu-Pai Liu (*Chinese Traditional*)
|
||||||
|
- Amarin Cemthong (*Thai*)
|
||||||
- juanda097 (*Spanish*)
|
- juanda097 (*Spanish*)
|
||||||
- Anunnakey (*Macedonian*)
|
- Anunnakey (*Macedonian*)
|
||||||
- Cutls (*Japanese*)
|
- StanleyFrew (*French*)
|
||||||
- erikstl (*Esperanto*)
|
- erikstl (*Esperanto*)
|
||||||
- ruine (*Japanese*)
|
|
||||||
- MadeInSteak (*Finnish*)
|
- MadeInSteak (*Finnish*)
|
||||||
- Sokratis Alichanidis (*Greek*)
|
- Heimen Stoffels (*Dutch*)
|
||||||
- dragnucs2 (*Arabic*)
|
- Rajarshi Guha (*Bengali*)
|
||||||
- frumble (*German*)
|
- Andrew (*Romanian*)
|
||||||
- Rikard Linde (*Swedish*)
|
- Goudarz Jafari (*Persian*)
|
||||||
- PPNplus (*Thai*)
|
|
||||||
- arethsu (*Swedish*)
|
- arethsu (*Swedish*)
|
||||||
- EPEMA YT (*German*)
|
- Carlos Solís (*Esperanto*)
|
||||||
|
- Parthan S Ramanujam (*Tamil*)
|
||||||
|
- Ali Demirtaş (*Turkish*)
|
||||||
|
- Kasper Nymand (*Danish*)
|
||||||
|
- TS (*Finnish*)
|
||||||
|
- SensDeViata (*Ukrainian*)
|
||||||
|
- SergioFMiranda (*Portuguese, Brazilian*)
|
||||||
|
- OctolinGamer (*Portuguese, Brazilian*)
|
||||||
|
- AzureNya (*Chinese Simplified*)
|
||||||
|
- Ram varma (*Tamil*)
|
||||||
|
- 北䑓如法 (*Japanese*)
|
||||||
|
- frumble (*German*)
|
||||||
|
- kekkepikkuni (*Tamil*)
|
||||||
|
- oorsutri (*Tamil*)
|
||||||
|
- Nithin V (*Tamil*)
|
||||||
|
- Miro Rauhala (*Finnish*)
|
||||||
|
- diorama (*Italian*)
|
||||||
- Rhys Harrison (*Esperanto*)
|
- Rhys Harrison (*Esperanto*)
|
||||||
|
- Guillaume Turchini (*French*)
|
||||||
|
- Ganesh D (*Marathi*)
|
||||||
|
- dragnucs2 (*Arabic*)
|
||||||
|
- Pedro Henrique (*Portuguese, Brazilian*)
|
||||||
|
- Tejas Harad (*Marathi*)
|
||||||
|
- Vasanthan (*Tamil*)
|
||||||
|
- 硫酸鶏 (*Japanese*)
|
||||||
|
- manukp (*Malayalam*)
|
||||||
|
- psymyn (*Hebrew*)
|
||||||
|
- earth dweller (*Marathi*)
|
||||||
|
- meijerivoi (*Finnish*)
|
||||||
|
- essaar (*Tamil*)
|
||||||
|
- serubeena (*Swedish*)
|
||||||
|
- Rintan (*Japanese*)
|
||||||
|
- Karol Kosek (*Polish*)
|
||||||
|
- valarivan (*Tamil*)
|
||||||
|
- Sebastián Andil (*Slovak*)
|
||||||
|
- v4vachan (*Malayalam*)
|
||||||
- KEINOS (*Japanese*)
|
- KEINOS (*Japanese*)
|
||||||
|
- Ivan T. (*Chinese Traditional, Hong Kong*)
|
||||||
- filippodb (*Italian*)
|
- filippodb (*Italian*)
|
||||||
|
- Balázs Meskó (*Hungarian*)
|
||||||
- JzshAC (*Chinese Simplified*)
|
- JzshAC (*Chinese Simplified*)
|
||||||
- Rintan1 (*Japanese*)
|
- Bottle (*Tamil*)
|
||||||
|
- Khóo (*Chinese Traditional*)
|
||||||
|
- Steven Tappert (*German*)
|
||||||
- Antillion (*Spanish*)
|
- Antillion (*Spanish*)
|
||||||
|
- ZiriSut (*Kabyle*)
|
||||||
|
- gowthamanb (*Tamil*)
|
||||||
- hiphipvargas (*Portuguese*)
|
- hiphipvargas (*Portuguese*)
|
||||||
|
- Arttu Ylhävuori (*Finnish*)
|
||||||
- Ch. (*Korean*)
|
- Ch. (*Korean*)
|
||||||
- tctovsli (*Norwegian Nynorsk*)
|
- tctovsli (*Norwegian Nynorsk*)
|
||||||
|
- Hinaloe (*Japanese*)
|
||||||
|
- strubbl (*German*)
|
||||||
- vjasiegd (*Polish*)
|
- vjasiegd (*Polish*)
|
||||||
- SamitiMed (*Thai*)
|
- SamitiMed (*Thai*)
|
||||||
|
- Reg3xp (*Persian*)
|
||||||
|
- AlexKoala (*Korean*)
|
||||||
- umelard (*Hebrew*)
|
- umelard (*Hebrew*)
|
||||||
- 硫酸鶏 (*Japanese*)
|
- VSx86 (*Russian*)
|
||||||
- Adrián Lattes (*Spanish*)
|
|
||||||
- Hinaloe (*Japanese*)
|
|
||||||
- Renato "Lond" Cerqueira (*Portuguese, Brazilian*)
|
|
||||||
- parnikkapore (*Thai*)
|
|
||||||
- Marcin Mikołajczak (*Polish*)
|
|
||||||
- 森の子リスのミーコの大冒険 (*Japanese*)
|
|
||||||
- Marcepanek_ (*Polish*)
|
|
||||||
- Sahak Petrosyan (*Armenian*)
|
|
||||||
- Daniel Dimitrov (*Bulgarian*)
|
- Daniel Dimitrov (*Bulgarian*)
|
||||||
|
- mynameismonkey (*Welsh*)
|
||||||
|
- parnikkapore (*Thai*)
|
||||||
|
- Mo_der Steven (*Chinese Simplified*)
|
||||||
|
- SKELET (*Danish*)
|
||||||
|
- Renato "Lond" Cerqueira (*Portuguese, Brazilian*)
|
||||||
|
- enipra (*Armenian*)
|
||||||
|
- musix (*Persian*)
|
||||||
|
- ギャラ (*Chinese Simplified; Japanese*)
|
||||||
|
- ALEM FARID (*Kabyle*)
|
||||||
|
- ybardapurkar (*Marathi*)
|
||||||
|
- Adrián Lattes (*Spanish*)
|
||||||
|
- rasheedgm (*Kannada*)
|
||||||
|
- omquylzu (*Latvian*)
|
||||||
|
- Belkacem Mohammed (*Kabyle*)
|
||||||
|
- Navjot Singh (*Hindi*)
|
||||||
|
- Ozai (*German*)
|
||||||
|
- Sahak Petrosyan (*Armenian*)
|
||||||
|
- siamano (*Thai; Esperanto*)
|
||||||
|
- se7entime (*Indonesian*)
|
||||||
|
- Viorel-Cătălin Răpițeanu (*Romanian*)
|
||||||
|
- Siddhartha Sarathi Basu (*Bengali*)
|
||||||
|
- Pachara Chantawong (*Thai*)
|
||||||
|
- Skew (*French*)
|
||||||
|
- Zijian Zhao (*Chinese Simplified*)
|
||||||
|
- Guru Prasath Anandapadmanaban (*Tamil*)
|
||||||
|
- turtle836 (*German*)
|
||||||
|
- GatoOscuro (*Spanish*)
|
||||||
|
- Lamin (*Japanese*)
|
||||||
|
- Marcepanek_ (*Polish*)
|
||||||
|
- Yann Aguettaz (*French*)
|
||||||
|
- Feruz Oripov (*Russian*)
|
||||||
|
- Mick Onio (*Asturian*)
|
||||||
|
- hg6 (*Hindi*)
|
||||||
|
- Malik Mann (*German*)
|
||||||
|
- padulafacundo (*Spanish*)
|
||||||
|
- r3dsp1 (*Chinese Traditional, Hong Kong*)
|
||||||
|
- Tianqi Zhang (*Chinese Simplified*)
|
||||||
|
- Padraic Calpin (*Slovenian*)
|
||||||
|
- cenegd (*Chinese Simplified*)
|
||||||
|
- piupiupiudiu (*Chinese Simplified*)
|
||||||
- Hugh Liu (*Chinese Simplified*)
|
- Hugh Liu (*Chinese Simplified*)
|
||||||
- Rakino (*Chinese Simplified*)
|
- Rakino (*Chinese Simplified*)
|
||||||
|
- Jothipazhani Nagarajan (*Tamil*)
|
||||||
|
- Miquel Sabaté Solà (*Catalan*)
|
||||||
|
- AmazighNM (*Kabyle*)
|
||||||
|
- Solid Rhino (*Dutch*)
|
||||||
|
- hallomaurits (*Dutch*)
|
||||||
- hussama (*Portuguese, Brazilian*)
|
- hussama (*Portuguese, Brazilian*)
|
||||||
- ThibG (*French*)
|
- shafouz (*Portuguese, Brazilian*)
|
||||||
|
- Tagada (*French*)
|
||||||
|
- Tom_ (*Czech*)
|
||||||
- SnDer (*Dutch*)
|
- SnDer (*Dutch*)
|
||||||
- PifyZ (*French*)
|
|
||||||
- eichkat3r (*German*)
|
- eichkat3r (*German*)
|
||||||
- Karol Kosek (*Polish*)
|
- PifyZ (*French*)
|
||||||
- Akarshan Biswas (*Bengali*)
|
- OminousCry (*Russian*)
|
||||||
|
- Shrinivasan T (*Tamil*)
|
||||||
|
- Nathaël Noguès (*French*)
|
||||||
|
- Daniel M. (*Catalan*)
|
||||||
|
- Swati Sani (*Urdu*)
|
||||||
|
- Kk (*Kannada*)
|
||||||
|
- SusVersiva (*Catalan*)
|
||||||
|
- Robin van der Vliet (*Esperanto*)
|
||||||
|
- Zinkokooo (*Basque*)
|
||||||
- Tradjincal (*French*)
|
- Tradjincal (*French*)
|
||||||
- Steven Tappert (*German*)
|
- Vikatakavi (*Kannada*)
|
||||||
- sergioaraujo1 (*Portuguese, Brazilian*)
|
- prabhjot (*Hindi*)
|
||||||
|
- twpenguin (*Chinese Traditional*)
|
||||||
- mmokhi (*Persian*)
|
- mmokhi (*Persian*)
|
||||||
- fedot (*Russian*)
|
- sergioaraujo1 (*Portuguese, Brazilian*)
|
||||||
|
- Livingston Samuel (*Tamil*)
|
||||||
|
- tsundoker (*Malayalam*)
|
||||||
- skaaarrr (*German*)
|
- skaaarrr (*German*)
|
||||||
|
- 夜楓Yoka (*Chinese Simplified*)
|
||||||
|
- kiwi0 (*Italian*)
|
||||||
|
- fedot (*Russian*)
|
||||||
|
- mkljczk (*Polish*)
|
||||||
|
- igordrozniak (*Polish*)
|
||||||
|
- Ricardo Colin (*Spanish*)
|
||||||
|
- Esther (*Portuguese*)
|
||||||
|
- Paz Galindo (*Spanish*)
|
||||||
|
- Philipp Fischbeck (*German*)
|
||||||
|
- ralozkolya (*Georgian*)
|
||||||
- JackXu (*Chinese Simplified*)
|
- JackXu (*Chinese Simplified*)
|
||||||
- Lukas Fülling (*German*)
|
- Allen Zhong (*Chinese Simplified*)
|
||||||
- Zoé Bőle (*German*)
|
- Zoé Bőle (*German*)
|
||||||
|
- Lukas Fülling (*German*)
|
||||||
|
- Albatroz Jeremias (*Portuguese*)
|
||||||
|
- Samir Tighzert (*Kabyle*)
|
||||||
|
- Nocta (*French*)
|
||||||
|
- Anoop (*Malayalam*)
|
||||||
|
- pezcurrel (*Italian*)
|
||||||
- Dremski (*Bulgarian*)
|
- Dremski (*Bulgarian*)
|
||||||
|
- Aymeric (*French*)
|
||||||
- tamaina (*Japanese*)
|
- tamaina (*Japanese*)
|
||||||
|
- Doug (*Portuguese, Brazilian*)
|
||||||
|
- Matias Lavik (*Norwegian Nynorsk*)
|
||||||
|
- Fleva (*Sardinian*)
|
||||||
- OpenAlgeria (*Arabic*)
|
- OpenAlgeria (*Arabic*)
|
||||||
|
- koppe-pan (*Japanese*)
|
||||||
|
- Amith Raj Shetty (*Kannada*)
|
||||||
|
- smedvedev (*Russian*)
|
||||||
|
- Trond Boksasp (*Norwegian*)
|
||||||
|
- random_person (*Spanish*)
|
||||||
|
- Sais Lakshmanan (*Tamil*)
|
||||||
|
- mikel (*Spanish*)
|
||||||
|
- Mohammad Adnan Mahmood (*Arabic*)
|
||||||
|
153
CHANGELOG.md
153
CHANGELOG.md
@ -3,6 +3,159 @@ Changelog
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [3.2.0] - 2020-07-27
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `SMTP_SSL` environment variable ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14309))
|
||||||
|
- Add hotkey for toggling content warning input in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13987))
|
||||||
|
- **Add e-mail-based sign in challenge for users with disabled 2FA** ([Gargron](https://github.com/tootsuite/mastodon/pull/14013))
|
||||||
|
- If user tries signing in after:
|
||||||
|
- Being inactive for a while
|
||||||
|
- With a previously unknown IP
|
||||||
|
- Without 2FA being enabled
|
||||||
|
- Require to enter a token sent via e-mail before sigining in
|
||||||
|
- Add `limit` param to RSS feeds ([noellabo](https://github.com/tootsuite/mastodon/pull/13743))
|
||||||
|
- Add `visibility` param to share page ([noellabo](https://github.com/tootsuite/mastodon/pull/13023))
|
||||||
|
- Add blurhash to link previews ([ThibG](https://github.com/tootsuite/mastodon/pull/13984), [ThibG](https://github.com/tootsuite/mastodon/pull/14143), [ThibG](https://github.com/tootsuite/mastodon/pull/13985), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14267), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14278), [ThibG](https://github.com/tootsuite/mastodon/pull/14126), [ThibG](https://github.com/tootsuite/mastodon/pull/14261), [ThibG](https://github.com/tootsuite/mastodon/pull/14260))
|
||||||
|
- In web UI, toots cannot be marked as sensitive unless there is media attached
|
||||||
|
- However, it's possible to do via API or ActivityPub
|
||||||
|
- Thumnails of link previews of such posts now use blurhash in web UI
|
||||||
|
- The Card entity in REST API has a new `blurhash` attribute
|
||||||
|
- Add support for `summary` field for media description in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/13763))
|
||||||
|
- Add hints about incomplete remote content to web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14031), [noellabo](https://github.com/tootsuite/mastodon/pull/14195))
|
||||||
|
- **Add personal notes for accounts** ([ThibG](https://github.com/tootsuite/mastodon/pull/14148), [Gargron](https://github.com/tootsuite/mastodon/pull/14208), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14251))
|
||||||
|
- To clarify, these are notes only you can see, to help you remember details
|
||||||
|
- Notes can be viewed and edited from profiles in web UI
|
||||||
|
- New REST API: `POST /api/v1/accounts/:id/note` with `comment` param
|
||||||
|
- The Relationship entity in REST API has a new `note` attribute
|
||||||
|
- Add Helm chart ([dunn](https://github.com/tootsuite/mastodon/pull/14090), [dunn](https://github.com/tootsuite/mastodon/pull/14256), [dunn](https://github.com/tootsuite/mastodon/pull/14245))
|
||||||
|
- **Add customizable thumbnails for audio and video attachments** ([Gargron](https://github.com/tootsuite/mastodon/pull/14145), [Gargron](https://github.com/tootsuite/mastodon/pull/14244), [Gargron](https://github.com/tootsuite/mastodon/pull/14273), [Gargron](https://github.com/tootsuite/mastodon/pull/14203), [ThibG](https://github.com/tootsuite/mastodon/pull/14255), [ThibG](https://github.com/tootsuite/mastodon/pull/14306), [noellabo](https://github.com/tootsuite/mastodon/pull/14358), [noellabo](https://github.com/tootsuite/mastodon/pull/14357))
|
||||||
|
- Metadata (album, artist, etc) is no longer stripped from audio files
|
||||||
|
- Album art is automatically extracted from audio files
|
||||||
|
- Thumbnail can be manually uploaded for both audio and video attachments
|
||||||
|
- Media upload APIs now support `thumbnail` param
|
||||||
|
- On `POST /api/v1/media` and `POST /api/v2/media`
|
||||||
|
- And on `PUT /api/v1/media/:id`
|
||||||
|
- ActivityPub representation of media attachments represents custom thumbnails with an `icon` attribute
|
||||||
|
- The Media Attachment entity in REST API now has a `preview_remote_url` to its `preview_url`, equivalent to `remote_url` to its `url`
|
||||||
|
- **Add color extraction for thumbnails** ([Gargron](https://github.com/tootsuite/mastodon/pull/14209), [ThibG](https://github.com/tootsuite/mastodon/pull/14264))
|
||||||
|
- The `meta` attribute on the Media Attachment entity in REST API can now have a `colors` attribute which in turn contains three hex colors: `background`, `foreground`, and `accent`
|
||||||
|
- The background color is chosen from the most dominant color around the edges of the thumbnail
|
||||||
|
- The foreground and accent colors are chosen from the colors that are the most different from the background color using the CIEDE2000 algorithm
|
||||||
|
- The most satured color of the two is designated as the accent color
|
||||||
|
- The one with the highest W3C contrast is designated as the foreground color
|
||||||
|
- If there are not enough colors in the thumbnail, new ones are generated using a monochrome pattern
|
||||||
|
- Add a visibility indicator to toots in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14123), [highemerly](https://github.com/tootsuite/mastodon/pull/14292))
|
||||||
|
- Add `tootctl email_domain_blocks` ([tateisu](https://github.com/tootsuite/mastodon/pull/13589), [Gargron](https://github.com/tootsuite/mastodon/pull/14147))
|
||||||
|
- Add "Add new domain block" to header of federation page in admin UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13934))
|
||||||
|
- Add ability to keep emoji picker open with ctrl+click in web UI ([bclindner](https://github.com/tootsuite/mastodon/pull/13896), [noellabo](https://github.com/tootsuite/mastodon/pull/14096))
|
||||||
|
- Add custom icon for private boosts in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14380))
|
||||||
|
- Add support for Create and Update activities that don't inline objects in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/14359))
|
||||||
|
- Add support for Undo activities that don't inline activities in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/14346))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change `.env.production.sample` to be leaner and cleaner ([Gargron](https://github.com/tootsuite/mastodon/pull/14206))
|
||||||
|
- It was overloaded as de-facto documentation and getting quite crowded
|
||||||
|
- Defer to the actual documentation while still giving a minimal example
|
||||||
|
- Change `tootctl search deploy` to work faster and display progress ([Gargron](https://github.com/tootsuite/mastodon/pull/14300))
|
||||||
|
- Change User-Agent of link preview fetching service to include "Bot" ([Gargron](https://github.com/tootsuite/mastodon/pull/14248))
|
||||||
|
- Some websites may not render OpenGraph tags into HTML if that's not the case
|
||||||
|
- Change behaviour to carry blocks over when someone migrates their followers ([ThibG](https://github.com/tootsuite/mastodon/pull/14144))
|
||||||
|
- Change volume control and download buttons in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14122))
|
||||||
|
- **Change design of audio players in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/14095), [ThibG](https://github.com/tootsuite/mastodon/pull/14281), [Gargron](https://github.com/tootsuite/mastodon/pull/14282), [ThibG](https://github.com/tootsuite/mastodon/pull/14118), [Gargron](https://github.com/tootsuite/mastodon/pull/14199), [ThibG](https://github.com/tootsuite/mastodon/pull/14338))
|
||||||
|
- Change reply filter to never filter own toots in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14128))
|
||||||
|
- Change boost button to no longer serve as visibility indicator in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14132), [ThibG](https://github.com/tootsuite/mastodon/pull/14373))
|
||||||
|
- Change contrast of flash messages ([cchoi12](https://github.com/tootsuite/mastodon/pull/13892))
|
||||||
|
- Change wording from "Hide media" to "Hide image/images" in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13834))
|
||||||
|
- Change appearence of settings pages to be more consistent ([ariasuni](https://github.com/tootsuite/mastodon/pull/13938))
|
||||||
|
- Change "Add media" tooltip to not include long list of formats in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13954))
|
||||||
|
- Change how badly contrasting emoji are rendered in web UI ([leo60228](https://github.com/tootsuite/mastodon/pull/13773), [ThibG](https://github.com/tootsuite/mastodon/pull/13772), [mfmfuyu](https://github.com/tootsuite/mastodon/pull/14020), [ThibG](https://github.com/tootsuite/mastodon/pull/14015))
|
||||||
|
- Change structure of unavailable content section on about page ([ariasuni](https://github.com/tootsuite/mastodon/pull/13930))
|
||||||
|
- Change behaviour to accept ActivityPub activities relayed through group actor ([noellabo](https://github.com/tootsuite/mastodon/pull/14279))
|
||||||
|
- Change amount of processing retries for ActivityPub activities ([noellabo](https://github.com/tootsuite/mastodon/pull/14355))
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Remove the terms "blacklist" and "whitelist" from UX ([Gargron](https://github.com/tootsuite/mastodon/pull/14149), [mayaeh](https://github.com/tootsuite/mastodon/pull/14192))
|
||||||
|
- Environment variables changed (old versions continue to work):
|
||||||
|
- `WHITELIST_MODE` → `LIMITED_FEDERATION_MODE`
|
||||||
|
- `EMAIL_DOMAIN_BLACKLIST` → `EMAIL_DOMAIN_DENYLIST`
|
||||||
|
- `EMAIL_DOMAIN_WHITELIST` → `EMAIL_DOMAIN_ALLOWLIST`
|
||||||
|
- CLI option changed:
|
||||||
|
- `tootctl domains purge --whitelist-mode` → `tootctl domains purge --limited-federation-mode`
|
||||||
|
- Remove some unnecessary database indices ([lfuelling](https://github.com/tootsuite/mastodon/pull/13695), [noellabo](https://github.com/tootsuite/mastodon/pull/14259))
|
||||||
|
- Remove unnecessary Node.js version upper bound ([ykzts](https://github.com/tootsuite/mastodon/pull/14139))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix `following` param not working when exact match is found in account search ([noellabo](https://github.com/tootsuite/mastodon/pull/14394))
|
||||||
|
- Fix sometimes occuring duplicate mention notifications ([noellabo](https://github.com/tootsuite/mastodon/pull/14378))
|
||||||
|
- Fix RSS feeds not being cachable ([ThibG](https://github.com/tootsuite/mastodon/pull/14368))
|
||||||
|
- Fix lack of locking around processing of Announce activities in ActivityPub ([noellabo](https://github.com/tootsuite/mastodon/pull/14365))
|
||||||
|
- Fix boosted toots from blocked account not being retroactively removed from TL ([ThibG](https://github.com/tootsuite/mastodon/pull/14339))
|
||||||
|
- Fix large shortened numbers (like 1.2K) using incorrect pluralization ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14061))
|
||||||
|
- Fix streaming server trying to use empty password to connect to Redis when `REDIS_PASSWORD` is given but blank ([ThibG](https://github.com/tootsuite/mastodon/pull/14135))
|
||||||
|
- Fix being unable to unboost posts when blocked by their author ([ThibG](https://github.com/tootsuite/mastodon/pull/14308))
|
||||||
|
- Fix account domain block not properly unfollowing accounts from domain ([Gargron](https://github.com/tootsuite/mastodon/pull/14304))
|
||||||
|
- Fix removing a domain allow wiping known accounts in open federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14298))
|
||||||
|
- Fix blocks and mutes pagination in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14275))
|
||||||
|
- Fix new posts pushing down origin of opened dropdown in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14271), [ThibG](https://github.com/tootsuite/mastodon/pull/14348))
|
||||||
|
- Fix timeline markers not being saved sometimes ([ThibG](https://github.com/tootsuite/mastodon/pull/13887), [ThibG](https://github.com/tootsuite/mastodon/pull/13889), [ThibG](https://github.com/tootsuite/mastodon/pull/14155))
|
||||||
|
- Fix CSV uploads being rejected ([noellabo](https://github.com/tootsuite/mastodon/pull/13835))
|
||||||
|
- Fix incompatibility with ElasticSearch 7.x ([noellabo](https://github.com/tootsuite/mastodon/pull/13828))
|
||||||
|
- Fix being able to search posts where you're in the target audience but not actively mentioned ([noellabo](https://github.com/tootsuite/mastodon/pull/13829))
|
||||||
|
- Fix non-local posts appearing on local-only hashtag timelines in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/13827))
|
||||||
|
- Fix `tootctl media remove-orphans` choking on unknown files in storage ([Gargron](https://github.com/tootsuite/mastodon/pull/13765))
|
||||||
|
- Fix `tootctl upgrade storage-schema` misbehaving ([Gargron](https://github.com/tootsuite/mastodon/pull/13761), [angristan](https://github.com/tootsuite/mastodon/pull/13768))
|
||||||
|
- Fix it marking records as upgraded even though no files were moved
|
||||||
|
- Fix it not working with S3 storage
|
||||||
|
- Fix it not working with custom emojis
|
||||||
|
- Fix GIF reader raising incorrect exceptions ([ThibG](https://github.com/tootsuite/mastodon/pull/13760))
|
||||||
|
- Fix hashtag search performing account search as well ([ThibG](https://github.com/tootsuite/mastodon/pull/13758))
|
||||||
|
- Fix Webfinger returning wrong status code on malformed or missing param ([ThibG](https://github.com/tootsuite/mastodon/pull/13759))
|
||||||
|
- Fix `rake mastodon:setup` error when some environment variables are set ([ThibG](https://github.com/tootsuite/mastodon/pull/13928))
|
||||||
|
- Fix admin page crashing when trying to block an invalid domain name in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13884))
|
||||||
|
- Fix unsent toot confirmation dialog not popping up in single column mode in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13888))
|
||||||
|
- Fix performance of follow import ([noellabo](https://github.com/tootsuite/mastodon/pull/13836))
|
||||||
|
- Reduce timeout of Webfinger requests to that of other requests
|
||||||
|
- Use circuit breakers to stop hitting unresponsive servers
|
||||||
|
- Avoid hitting servers that are already known to be generally unavailable
|
||||||
|
- Fix filters ignoring media descriptions ([BenLubar](https://github.com/tootsuite/mastodon/pull/13837))
|
||||||
|
- Fix some actions on custom emojis leading to cryptic errors in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13951))
|
||||||
|
- Fix ActivityPub serialization of replies when some of them are URIs ([ThibG](https://github.com/tootsuite/mastodon/pull/13957))
|
||||||
|
- Fix `rake mastodon:setup` choking on environment variables containing `%` ([ThibG](https://github.com/tootsuite/mastodon/pull/13940))
|
||||||
|
- Fix account redirect confirmation message talking about moved followers ([ThibG](https://github.com/tootsuite/mastodon/pull/13950))
|
||||||
|
- Fix avatars having the wrong size on public detailed status pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14140))
|
||||||
|
- Fix various issues around OpenGraph representation of media ([Gargron](https://github.com/tootsuite/mastodon/pull/14133))
|
||||||
|
- Pages containing audio no longer say "Attached: 1 image" in description
|
||||||
|
- Audio attachments now represented as OpenGraph `og:audio`
|
||||||
|
- The `twitter:player` page now uses Mastodon's proper audio/video player
|
||||||
|
- Audio/video buffered bars now display correctly in audio/video player
|
||||||
|
- Volume and progress bars now respond to movement/move smoother
|
||||||
|
- Fix audio/video/images/cards not reacting to window resizes in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14130))
|
||||||
|
- Fix very wide media attachments resulting in too thin a thumbnail in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14127))
|
||||||
|
- Fix crash when merging posts into home feed after following someone ([ThibG](https://github.com/tootsuite/mastodon/pull/14129))
|
||||||
|
- Fix unique username constraint for local users not being enforced in database ([ThibG](https://github.com/tootsuite/mastodon/pull/14099))
|
||||||
|
- Fix unnecessary gap under video modal in web UI ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/14098))
|
||||||
|
- Fix 2FA and sign in token pages not respecting user locale ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/14087))
|
||||||
|
- Fix unapproved users being able to view profiles when in limited-federation mode *and* requiring approval for sign-ups ([ThibG](https://github.com/tootsuite/mastodon/pull/14093))
|
||||||
|
- Fix initial audio volume not corresponding to what's displayed in audio player in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14057))
|
||||||
|
- Fix timelines sometimes jumping when closing modals in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14019))
|
||||||
|
- Fix memory usage of downloading remote files ([Gargron](https://github.com/tootsuite/mastodon/pull/14184), [Gargron](https://github.com/tootsuite/mastodon/pull/14181), [noellabo](https://github.com/tootsuite/mastodon/pull/14356))
|
||||||
|
- Don't read entire file (up to 40 MB) into memory
|
||||||
|
- Read and write it to temp file in small chunks
|
||||||
|
- Fix inconsistent account header padding in web UI ([trwnh](https://github.com/tootsuite/mastodon/pull/14179))
|
||||||
|
- Fix Thai being skipped from language detection ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/13989))
|
||||||
|
- Since Thai has its own alphabet, it can be detected more reliably
|
||||||
|
- Fix broken hashtag column options styling in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14247))
|
||||||
|
- Fix pointer cursor being shown on toots that are not clickable in web UI ([arielrodrigues](https://github.com/tootsuite/mastodon/pull/14185))
|
||||||
|
- Fix lock icon not being shown when locking account in profile settings ([ThibG](https://github.com/tootsuite/mastodon/pull/14190))
|
||||||
|
- Fix domain blocks doing work the wrong way around ([ThibG](https://github.com/tootsuite/mastodon/pull/13424))
|
||||||
|
- Instead of suspending accounts one by one, mark all as suspended first (quick)
|
||||||
|
- Only then proceed to start removing their data (slow)
|
||||||
|
- Clear out media attachments in a separate worker (slow)
|
||||||
|
|
||||||
## [v3.1.5] - 2020-07-07
|
## [v3.1.5] - 2020-07-07
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
10
Dockerfile
10
Dockerfile
@ -1,10 +1,10 @@
|
|||||||
FROM ubuntu:18.04 as build-dep
|
FROM ubuntu:20.04 as build-dep
|
||||||
|
|
||||||
# Use bash for the shell
|
# Use bash for the shell
|
||||||
SHELL ["bash", "-c"]
|
SHELL ["bash", "-c"]
|
||||||
|
|
||||||
# Install Node v12 (LTS)
|
# Install Node v12 (LTS)
|
||||||
ENV NODE_VER="12.16.1"
|
ENV NODE_VER="12.16.3"
|
||||||
RUN ARCH= && \
|
RUN ARCH= && \
|
||||||
dpkgArch="$(dpkg --print-architecture)" && \
|
dpkgArch="$(dpkg --print-architecture)" && \
|
||||||
case "${dpkgArch##*-}" in \
|
case "${dpkgArch##*-}" in \
|
||||||
@ -74,7 +74,7 @@ RUN cd /opt/mastodon && \
|
|||||||
bundle install -j$(nproc) && \
|
bundle install -j$(nproc) && \
|
||||||
yarn install --pure-lockfile
|
yarn install --pure-lockfile
|
||||||
|
|
||||||
FROM ubuntu:18.04
|
FROM ubuntu:20.04
|
||||||
|
|
||||||
# Copy over all the langs needed for runtime
|
# Copy over all the langs needed for runtime
|
||||||
COPY --from=build-dep /opt/node /opt/node
|
COPY --from=build-dep /opt/node /opt/node
|
||||||
@ -98,8 +98,8 @@ RUN apt update && \
|
|||||||
# Install mastodon runtime deps
|
# Install mastodon runtime deps
|
||||||
RUN apt -y --no-install-recommends install \
|
RUN apt -y --no-install-recommends install \
|
||||||
libssl1.1 libpq5 imagemagick ffmpeg \
|
libssl1.1 libpq5 imagemagick ffmpeg \
|
||||||
libicu60 libprotobuf10 libidn11 libyaml-0-2 \
|
libicu66 libprotobuf17 libidn11 libyaml-0-2 \
|
||||||
file ca-certificates tzdata libreadline7 && \
|
file ca-certificates tzdata libreadline8 && \
|
||||||
apt -y install gcc && \
|
apt -y install gcc && \
|
||||||
ln -s /opt/mastodon /mastodon && \
|
ln -s /opt/mastodon /mastodon && \
|
||||||
gem install bundler && \
|
gem install bundler && \
|
||||||
|
33
Gemfile
33
Gemfile
@ -6,10 +6,10 @@ ruby '>= 2.5.0', '< 3.0.0'
|
|||||||
gem 'pkg-config', '~> 1.4'
|
gem 'pkg-config', '~> 1.4'
|
||||||
|
|
||||||
gem 'puma', '~> 4.3'
|
gem 'puma', '~> 4.3'
|
||||||
gem 'rails', '~> 5.2.4.2'
|
gem 'rails', '~> 5.2.4.3'
|
||||||
gem 'sprockets', '~> 3.7.2'
|
gem 'sprockets', '~> 3.7.2'
|
||||||
gem 'thor', '~> 0.20'
|
gem 'thor', '~> 0.20'
|
||||||
gem 'rack', '~> 2.2.2'
|
gem 'rack', '~> 2.2.3'
|
||||||
|
|
||||||
gem 'thwait', '~> 0.1.0'
|
gem 'thwait', '~> 0.1.0'
|
||||||
gem 'e2mmap', '~> 0.1.0'
|
gem 'e2mmap', '~> 0.1.0'
|
||||||
@ -17,10 +17,10 @@ gem 'e2mmap', '~> 0.1.0'
|
|||||||
gem 'hamlit-rails', '~> 0.2'
|
gem 'hamlit-rails', '~> 0.2'
|
||||||
gem 'pg', '~> 1.2'
|
gem 'pg', '~> 1.2'
|
||||||
gem 'makara', '~> 0.4'
|
gem 'makara', '~> 0.4'
|
||||||
gem 'pghero', '~> 2.4'
|
gem 'pghero', '~> 2.5'
|
||||||
gem 'dotenv-rails', '~> 2.7'
|
gem 'dotenv-rails', '~> 2.7'
|
||||||
|
|
||||||
gem 'aws-sdk-s3', '~> 1.64', require: false
|
gem 'aws-sdk-s3', '~> 1.73', require: false
|
||||||
gem 'fog-core', '<= 2.1.0'
|
gem 'fog-core', '<= 2.1.0'
|
||||||
gem 'fog-openstack', '~> 0.3', require: false
|
gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
@ -48,8 +48,10 @@ gem 'omniauth-cas', '~> 1.1'
|
|||||||
gem 'omniauth-saml', '~> 1.10'
|
gem 'omniauth-saml', '~> 1.10'
|
||||||
gem 'omniauth', '~> 1.9'
|
gem 'omniauth', '~> 1.9'
|
||||||
|
|
||||||
|
gem 'color_diff', '~> 0.1'
|
||||||
gem 'discard', '~> 1.2'
|
gem 'discard', '~> 1.2'
|
||||||
gem 'doorkeeper', '~> 5.4'
|
gem 'doorkeeper', '~> 5.4'
|
||||||
|
gem 'ed25519', '~> 1.2'
|
||||||
gem 'fast_blank', '~> 1.0'
|
gem 'fast_blank', '~> 1.0'
|
||||||
gem 'fastimage'
|
gem 'fastimage'
|
||||||
gem 'goldfinger', '~> 2.1'
|
gem 'goldfinger', '~> 2.1'
|
||||||
@ -60,7 +62,7 @@ gem 'htmlentities', '~> 4.3'
|
|||||||
gem 'http', '~> 4.4'
|
gem 'http', '~> 4.4'
|
||||||
gem 'http_accept_language', '~> 2.1'
|
gem 'http_accept_language', '~> 2.1'
|
||||||
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true
|
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true
|
||||||
gem 'httplog', '~> 1.4.2'
|
gem 'httplog', '~> 1.4.3'
|
||||||
gem 'idn-ruby', require: 'idn'
|
gem 'idn-ruby', require: 'idn'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
@ -79,11 +81,11 @@ gem 'rack-attack', '~> 6.3'
|
|||||||
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
|
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
|
||||||
gem 'rails-i18n', '~> 5.1'
|
gem 'rails-i18n', '~> 5.1'
|
||||||
gem 'rails-settings-cached', '~> 0.6'
|
gem 'rails-settings-cached', '~> 0.6'
|
||||||
gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
|
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis']
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'rqrcode', '~> 1.1'
|
gem 'rqrcode', '~> 1.1'
|
||||||
gem 'ruby-progressbar', '~> 1.10'
|
gem 'ruby-progressbar', '~> 1.10'
|
||||||
gem 'sanitize', '~> 5.1'
|
gem 'sanitize', '~> 5.2'
|
||||||
gem 'sidekiq', '~> 6.0'
|
gem 'sidekiq', '~> 6.0'
|
||||||
gem 'sidekiq-scheduler', '~> 3.0'
|
gem 'sidekiq-scheduler', '~> 3.0'
|
||||||
gem 'sidekiq-unique-jobs', '~> 6.0'
|
gem 'sidekiq-unique-jobs', '~> 6.0'
|
||||||
@ -93,7 +95,6 @@ gem 'simple_form', '~> 5.0'
|
|||||||
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
|
||||||
gem 'stoplight', '~> 2.2.0'
|
gem 'stoplight', '~> 2.2.0'
|
||||||
gem 'strong_migrations', '~> 0.6'
|
gem 'strong_migrations', '~> 0.6'
|
||||||
gem 'tty-command', '~> 0.9', require: false
|
|
||||||
gem 'tty-prompt', '~> 0.21', require: false
|
gem 'tty-prompt', '~> 0.21', require: false
|
||||||
gem 'twitter-text', '~> 1.14'
|
gem 'twitter-text', '~> 1.14'
|
||||||
gem 'tzinfo-data', '~> 1.2020'
|
gem 'tzinfo-data', '~> 1.2020'
|
||||||
@ -118,15 +119,15 @@ group :production, :test do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem 'capybara', '~> 3.32'
|
gem 'capybara', '~> 3.33'
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control', '~> 0.2'
|
||||||
gem 'faker', '~> 2.11'
|
gem 'faker', '~> 2.13'
|
||||||
gem 'microformats', '~> 4.2'
|
gem 'microformats', '~> 4.2'
|
||||||
gem 'rails-controller-testing', '~> 1.0'
|
gem 'rails-controller-testing', '~> 1.0'
|
||||||
gem 'rspec-sidekiq', '~> 3.0'
|
gem 'rspec-sidekiq', '~> 3.1'
|
||||||
gem 'simplecov', '~> 0.18', require: false
|
gem 'simplecov', '~> 0.18', require: false
|
||||||
gem 'webmock', '~> 3.8'
|
gem 'webmock', '~> 3.8'
|
||||||
gem 'parallel_tests', '~> 2.32'
|
gem 'parallel_tests', '~> 3.0'
|
||||||
gem 'rspec_junit_formatter', '~> 0.4'
|
gem 'rspec_junit_formatter', '~> 0.4'
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -139,13 +140,13 @@ group :development do
|
|||||||
gem 'letter_opener', '~> 1.7'
|
gem 'letter_opener', '~> 1.7'
|
||||||
gem 'letter_opener_web', '~> 1.4'
|
gem 'letter_opener_web', '~> 1.4'
|
||||||
gem 'memory_profiler'
|
gem 'memory_profiler'
|
||||||
gem 'rubocop', '~> 0.82', require: false
|
gem 'rubocop', '~> 0.86', require: false
|
||||||
gem 'rubocop-rails', '~> 2.5', require: false
|
gem 'rubocop-rails', '~> 2.6', require: false
|
||||||
gem 'brakeman', '~> 4.8', require: false
|
gem 'brakeman', '~> 4.8', require: false
|
||||||
gem 'bundler-audit', '~> 0.6', require: false
|
gem 'bundler-audit', '~> 0.7', require: false
|
||||||
|
|
||||||
gem 'capistrano', '~> 3.14'
|
gem 'capistrano', '~> 3.14'
|
||||||
gem 'capistrano-rails', '~> 1.4'
|
gem 'capistrano-rails', '~> 1.5'
|
||||||
gem 'capistrano-rbenv', '~> 2.1'
|
gem 'capistrano-rbenv', '~> 2.1'
|
||||||
gem 'capistrano-yarn', '~> 2.0'
|
gem 'capistrano-yarn', '~> 2.0'
|
||||||
|
|
||||||
|
280
Gemfile.lock
280
Gemfile.lock
@ -31,25 +31,25 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (5.2.4.2)
|
actioncable (5.2.4.3)
|
||||||
actionpack (= 5.2.4.2)
|
actionpack (= 5.2.4.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailer (5.2.4.2)
|
actionmailer (5.2.4.3)
|
||||||
actionpack (= 5.2.4.2)
|
actionpack (= 5.2.4.3)
|
||||||
actionview (= 5.2.4.2)
|
actionview (= 5.2.4.3)
|
||||||
activejob (= 5.2.4.2)
|
activejob (= 5.2.4.3)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (5.2.4.2)
|
actionpack (5.2.4.3)
|
||||||
actionview (= 5.2.4.2)
|
actionview (= 5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
rack (~> 2.0, >= 2.0.8)
|
rack (~> 2.0, >= 2.0.8)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||||
actionview (5.2.4.2)
|
actionview (5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
@ -60,20 +60,20 @@ GEM
|
|||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
active_record_query_trace (1.7)
|
active_record_query_trace (1.7)
|
||||||
activejob (5.2.4.2)
|
activejob (5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (5.2.4.2)
|
activemodel (5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
activerecord (5.2.4.2)
|
activerecord (5.2.4.3)
|
||||||
activemodel (= 5.2.4.2)
|
activemodel (= 5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
arel (>= 9.0)
|
arel (>= 9.0)
|
||||||
activestorage (5.2.4.2)
|
activestorage (5.2.4.3)
|
||||||
actionpack (= 5.2.4.2)
|
actionpack (= 5.2.4.3)
|
||||||
activerecord (= 5.2.4.2)
|
activerecord (= 5.2.4.3)
|
||||||
marcel (~> 0.3.1)
|
marcel (~> 0.3.1)
|
||||||
activesupport (5.2.4.2)
|
activesupport (5.2.4.3)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
minitest (~> 5.1)
|
minitest (~> 5.1)
|
||||||
@ -86,29 +86,29 @@ GEM
|
|||||||
activerecord (>= 3.2, < 7.0)
|
activerecord (>= 3.2, < 7.0)
|
||||||
rake (>= 10.4, < 14.0)
|
rake (>= 10.4, < 14.0)
|
||||||
arel (9.0.0)
|
arel (9.0.0)
|
||||||
ast (2.4.0)
|
ast (2.4.1)
|
||||||
attr_encrypted (3.1.0)
|
attr_encrypted (3.1.0)
|
||||||
encryptor (~> 3.0.0)
|
encryptor (~> 3.0.0)
|
||||||
av (0.9.0)
|
av (0.9.0)
|
||||||
cocaine (~> 0.5.3)
|
cocaine (~> 0.5.3)
|
||||||
aws-eventstream (1.1.0)
|
aws-eventstream (1.1.0)
|
||||||
aws-partitions (1.312.0)
|
aws-partitions (1.338.0)
|
||||||
aws-sdk-core (3.95.0)
|
aws-sdk-core (3.103.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.239.0)
|
aws-partitions (~> 1, >= 1.239.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
jmespath (~> 1.0)
|
jmespath (~> 1.0)
|
||||||
aws-sdk-kms (1.31.0)
|
aws-sdk-kms (1.36.0)
|
||||||
aws-sdk-core (~> 3, >= 3.71.0)
|
aws-sdk-core (~> 3, >= 3.99.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.64.0)
|
aws-sdk-s3 (1.73.0)
|
||||||
aws-sdk-core (~> 3, >= 3.83.0)
|
aws-sdk-core (~> 3, >= 3.102.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (1.1.3)
|
aws-sigv4 (1.2.1)
|
||||||
aws-eventstream (~> 1.0, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
bcrypt (3.1.13)
|
bcrypt (3.1.13)
|
||||||
better_errors (2.7.0)
|
better_errors (2.7.1)
|
||||||
coderay (>= 1.0.0)
|
coderay (>= 1.0.0)
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
@ -118,24 +118,24 @@ GEM
|
|||||||
ffi (~> 1.10.0)
|
ffi (~> 1.10.0)
|
||||||
bootsnap (1.4.6)
|
bootsnap (1.4.6)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
brakeman (4.8.1)
|
brakeman (4.8.2)
|
||||||
browser (4.1.0)
|
browser (4.2.0)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
bullet (6.1.0)
|
bullet (6.1.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
bundler-audit (0.6.1)
|
bundler-audit (0.7.0.1)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 0.18)
|
thor (>= 0.18, < 2)
|
||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
capistrano (3.14.0)
|
capistrano (3.14.1)
|
||||||
airbrussh (>= 1.0.0)
|
airbrussh (>= 1.0.0)
|
||||||
i18n
|
i18n
|
||||||
rake (>= 10.0.0)
|
rake (>= 10.0.0)
|
||||||
sshkit (>= 1.9.0)
|
sshkit (>= 1.9.0)
|
||||||
capistrano-bundler (1.6.0)
|
capistrano-bundler (1.6.0)
|
||||||
capistrano (~> 3.1)
|
capistrano (~> 3.1)
|
||||||
capistrano-rails (1.4.0)
|
capistrano-rails (1.5.0)
|
||||||
capistrano (~> 3.1)
|
capistrano (~> 3.1)
|
||||||
capistrano-bundler (~> 1.1)
|
capistrano-bundler (~> 1.1)
|
||||||
capistrano-rbenv (2.1.6)
|
capistrano-rbenv (2.1.6)
|
||||||
@ -143,7 +143,7 @@ GEM
|
|||||||
sshkit (~> 1.3)
|
sshkit (~> 1.3)
|
||||||
capistrano-yarn (2.0.2)
|
capistrano-yarn (2.0.2)
|
||||||
capistrano (~> 3.0)
|
capistrano (~> 3.0)
|
||||||
capybara (3.32.1)
|
capybara (3.33.0)
|
||||||
addressable
|
addressable
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
@ -164,16 +164,17 @@ GEM
|
|||||||
climate_control (0.2.0)
|
climate_control (0.2.0)
|
||||||
cocaine (0.5.8)
|
cocaine (0.5.8)
|
||||||
climate_control (>= 0.0.3, < 1.0)
|
climate_control (>= 0.0.3, < 1.0)
|
||||||
coderay (1.1.2)
|
coderay (1.1.3)
|
||||||
|
color_diff (0.1)
|
||||||
concurrent-ruby (1.1.6)
|
concurrent-ruby (1.1.6)
|
||||||
connection_pool (2.2.2)
|
connection_pool (2.2.3)
|
||||||
crack (0.4.3)
|
crack (0.4.3)
|
||||||
safe_yaml (~> 1.0.0)
|
safe_yaml (~> 1.0.0)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
css_parser (1.7.1)
|
css_parser (1.7.1)
|
||||||
addressable
|
addressable
|
||||||
debug_inspector (0.0.3)
|
debug_inspector (0.0.3)
|
||||||
devise (4.7.1)
|
devise (4.7.2)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
@ -188,7 +189,7 @@ GEM
|
|||||||
devise_pam_authenticatable2 (9.2.0)
|
devise_pam_authenticatable2 (9.2.0)
|
||||||
devise (>= 4.0.0)
|
devise (>= 4.0.0)
|
||||||
rpam2 (~> 4.0)
|
rpam2 (~> 4.0)
|
||||||
diff-lcs (1.3)
|
diff-lcs (1.4.4)
|
||||||
discard (1.2.0)
|
discard (1.2.0)
|
||||||
activerecord (>= 4.2, < 7)
|
activerecord (>= 4.2, < 7)
|
||||||
docile (1.3.2)
|
docile (1.3.2)
|
||||||
@ -201,13 +202,14 @@ GEM
|
|||||||
dotenv (= 2.7.5)
|
dotenv (= 2.7.5)
|
||||||
railties (>= 3.2, < 6.1)
|
railties (>= 3.2, < 6.1)
|
||||||
e2mmap (0.1.0)
|
e2mmap (0.1.0)
|
||||||
elasticsearch (7.6.0)
|
ed25519 (1.2.4)
|
||||||
elasticsearch-api (= 7.6.0)
|
elasticsearch (7.8.0)
|
||||||
elasticsearch-transport (= 7.6.0)
|
elasticsearch-api (= 7.8.0)
|
||||||
elasticsearch-api (7.6.0)
|
elasticsearch-transport (= 7.8.0)
|
||||||
|
elasticsearch-api (7.8.0)
|
||||||
multi_json
|
multi_json
|
||||||
elasticsearch-dsl (0.1.9)
|
elasticsearch-dsl (0.1.9)
|
||||||
elasticsearch-transport (7.6.0)
|
elasticsearch-transport (7.8.0)
|
||||||
faraday (~> 1)
|
faraday (~> 1)
|
||||||
multi_json
|
multi_json
|
||||||
encryptor (3.0.0)
|
encryptor (3.0.0)
|
||||||
@ -215,9 +217,9 @@ GEM
|
|||||||
erubi (1.9.0)
|
erubi (1.9.0)
|
||||||
et-orbi (1.2.4)
|
et-orbi (1.2.4)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (0.73.0)
|
excon (0.75.0)
|
||||||
fabrication (2.21.1)
|
fabrication (2.21.1)
|
||||||
faker (2.11.0)
|
faker (2.13.0)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
faraday (1.0.1)
|
faraday (1.0.1)
|
||||||
multipart-post (>= 1.2, < 3)
|
multipart-post (>= 1.2, < 3)
|
||||||
@ -235,14 +237,14 @@ GEM
|
|||||||
fog-json (1.2.0)
|
fog-json (1.2.0)
|
||||||
fog-core
|
fog-core
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
fog-openstack (0.3.7)
|
fog-openstack (0.3.10)
|
||||||
fog-core (>= 1.45, <= 2.1.0)
|
fog-core (>= 1.45, <= 2.1.0)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
ipaddress (>= 0.8)
|
ipaddress (>= 0.8)
|
||||||
formatador (0.2.5)
|
formatador (0.2.5)
|
||||||
fugit (1.3.5)
|
fugit (1.3.6)
|
||||||
et-orbi (~> 1.1, >= 1.1.8)
|
et-orbi (~> 1.1, >= 1.1.8)
|
||||||
raabro (~> 1.1)
|
raabro (~> 1.3)
|
||||||
fuubar (2.5.0)
|
fuubar (2.5.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
ruby-progressbar (~> 1.4)
|
ruby-progressbar (~> 1.4)
|
||||||
@ -281,10 +283,10 @@ GEM
|
|||||||
http-parser (1.2.1)
|
http-parser (1.2.1)
|
||||||
ffi-compiler (>= 1.0, < 2.0)
|
ffi-compiler (>= 1.0, < 2.0)
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httplog (1.4.2)
|
httplog (1.4.3)
|
||||||
rack (>= 1.0)
|
rack (>= 1.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.8.2)
|
i18n (1.8.3)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (0.9.31)
|
i18n-tasks (0.9.31)
|
||||||
activesupport (>= 4.0.2)
|
activesupport (>= 4.0.2)
|
||||||
@ -299,9 +301,8 @@ GEM
|
|||||||
idn-ruby (0.1.0)
|
idn-ruby (0.1.0)
|
||||||
ipaddress (0.8.3)
|
ipaddress (0.8.3)
|
||||||
iso-639 (0.3.5)
|
iso-639 (0.3.5)
|
||||||
jaro_winkler (1.5.4)
|
|
||||||
jmespath (1.4.0)
|
jmespath (1.4.0)
|
||||||
json (2.3.0)
|
json (2.3.1)
|
||||||
json-canonicalization (0.2.0)
|
json-canonicalization (0.2.0)
|
||||||
json-ld (3.1.4)
|
json-ld (3.1.4)
|
||||||
htmlentities (~> 4.3)
|
htmlentities (~> 4.3)
|
||||||
@ -310,23 +311,23 @@ GEM
|
|||||||
multi_json (~> 1.14)
|
multi_json (~> 1.14)
|
||||||
rack (~> 2.0)
|
rack (~> 2.0)
|
||||||
rdf (~> 3.1)
|
rdf (~> 3.1)
|
||||||
json-ld-preloaded (3.1.2)
|
json-ld-preloaded (3.1.3)
|
||||||
json-ld (~> 3.1)
|
json-ld (~> 3.1)
|
||||||
rdf (~> 3.1)
|
rdf (~> 3.1)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.2.1)
|
jwt (2.2.1)
|
||||||
kaminari (1.2.0)
|
kaminari (1.2.1)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
kaminari-actionview (= 1.2.0)
|
kaminari-actionview (= 1.2.1)
|
||||||
kaminari-activerecord (= 1.2.0)
|
kaminari-activerecord (= 1.2.1)
|
||||||
kaminari-core (= 1.2.0)
|
kaminari-core (= 1.2.1)
|
||||||
kaminari-actionview (1.2.0)
|
kaminari-actionview (1.2.1)
|
||||||
actionview
|
actionview
|
||||||
kaminari-core (= 1.2.0)
|
kaminari-core (= 1.2.1)
|
||||||
kaminari-activerecord (1.2.0)
|
kaminari-activerecord (1.2.1)
|
||||||
activerecord
|
activerecord
|
||||||
kaminari-core (= 1.2.0)
|
kaminari-core (= 1.2.1)
|
||||||
kaminari-core (1.2.0)
|
kaminari-core (1.2.1)
|
||||||
launchy (2.5.0)
|
launchy (2.5.0)
|
||||||
addressable (~> 2.7)
|
addressable (~> 2.7)
|
||||||
letter_opener (1.7.0)
|
letter_opener (1.7.0)
|
||||||
@ -341,7 +342,7 @@ GEM
|
|||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.5.0)
|
loofah (2.6.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.5.9)
|
nokogiri (>= 1.5.9)
|
||||||
mail (2.7.1)
|
mail (2.7.1)
|
||||||
@ -359,11 +360,11 @@ GEM
|
|||||||
nokogiri (~> 1.10)
|
nokogiri (~> 1.10)
|
||||||
mime-types (3.3.1)
|
mime-types (3.3.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2020.0425)
|
mime-types-data (3.2020.0512)
|
||||||
mimemagic (0.3.5)
|
mimemagic (0.3.5)
|
||||||
mini_mime (1.0.2)
|
mini_mime (1.0.2)
|
||||||
mini_portile2 (2.4.0)
|
mini_portile2 (2.4.0)
|
||||||
minitest (5.14.0)
|
minitest (5.14.1)
|
||||||
msgpack (1.3.3)
|
msgpack (1.3.3)
|
||||||
multi_json (1.14.1)
|
multi_json (1.14.1)
|
||||||
multipart-post (2.1.1)
|
multipart-post (2.1.1)
|
||||||
@ -371,7 +372,7 @@ GEM
|
|||||||
net-ldap (0.16.2)
|
net-ldap (0.16.2)
|
||||||
net-scp (3.0.0)
|
net-scp (3.0.0)
|
||||||
net-ssh (>= 2.6.5, < 7.0.0)
|
net-ssh (>= 2.6.5, < 7.0.0)
|
||||||
net-ssh (6.0.2)
|
net-ssh (6.1.0)
|
||||||
nio4r (2.5.2)
|
nio4r (2.5.2)
|
||||||
nokogiri (1.10.9)
|
nokogiri (1.10.9)
|
||||||
mini_portile2 (~> 2.4.0)
|
mini_portile2 (~> 2.4.0)
|
||||||
@ -390,9 +391,9 @@ GEM
|
|||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
nokogiri (~> 1.5)
|
nokogiri (~> 1.5)
|
||||||
omniauth (~> 1.2)
|
omniauth (~> 1.2)
|
||||||
omniauth-saml (1.10.1)
|
omniauth-saml (1.10.2)
|
||||||
omniauth (~> 1.3, >= 1.3.2)
|
omniauth (~> 1.3, >= 1.3.2)
|
||||||
ruby-saml (~> 1.7)
|
ruby-saml (~> 1.9)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ox (2.13.2)
|
ox (2.13.2)
|
||||||
paperclip (6.0.0)
|
paperclip (6.0.0)
|
||||||
@ -404,17 +405,17 @@ GEM
|
|||||||
paperclip-av-transcoder (0.6.4)
|
paperclip-av-transcoder (0.6.4)
|
||||||
av (~> 0.9.0)
|
av (~> 0.9.0)
|
||||||
paperclip (>= 2.5.2)
|
paperclip (>= 2.5.2)
|
||||||
parallel (1.19.1)
|
parallel (1.19.2)
|
||||||
parallel_tests (2.32.0)
|
parallel_tests (3.0.0)
|
||||||
parallel
|
parallel
|
||||||
parser (2.7.1.2)
|
parser (2.7.1.4)
|
||||||
ast (~> 2.4.0)
|
ast (~> 2.4.1)
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
pastel (0.7.4)
|
pastel (0.7.4)
|
||||||
equatable (~> 0.6)
|
equatable (~> 0.6)
|
||||||
tty-color (~> 0.5)
|
tty-color (~> 0.5)
|
||||||
pg (1.2.3)
|
pg (1.2.3)
|
||||||
pghero (2.4.2)
|
pghero (2.5.1)
|
||||||
activerecord (>= 5)
|
activerecord (>= 5)
|
||||||
pkg-config (1.4.1)
|
pkg-config (1.4.1)
|
||||||
premailer (1.11.1)
|
premailer (1.11.1)
|
||||||
@ -434,39 +435,37 @@ GEM
|
|||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (4.0.5)
|
public_suffix (4.0.5)
|
||||||
puma (4.3.3)
|
puma (4.3.5)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.1.0)
|
pundit (2.1.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.3.1)
|
raabro (1.3.1)
|
||||||
rack (2.2.2)
|
rack (2.2.3)
|
||||||
rack-attack (6.3.0)
|
rack-attack (6.3.1)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
rack-cors (1.1.1)
|
rack-cors (1.1.1)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-protection (2.0.8.1)
|
|
||||||
rack
|
|
||||||
rack-proxy (0.6.5)
|
rack-proxy (0.6.5)
|
||||||
rack
|
rack
|
||||||
rack-test (1.1.0)
|
rack-test (1.1.0)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
rails (5.2.4.2)
|
rails (5.2.4.3)
|
||||||
actioncable (= 5.2.4.2)
|
actioncable (= 5.2.4.3)
|
||||||
actionmailer (= 5.2.4.2)
|
actionmailer (= 5.2.4.3)
|
||||||
actionpack (= 5.2.4.2)
|
actionpack (= 5.2.4.3)
|
||||||
actionview (= 5.2.4.2)
|
actionview (= 5.2.4.3)
|
||||||
activejob (= 5.2.4.2)
|
activejob (= 5.2.4.3)
|
||||||
activemodel (= 5.2.4.2)
|
activemodel (= 5.2.4.3)
|
||||||
activerecord (= 5.2.4.2)
|
activerecord (= 5.2.4.3)
|
||||||
activestorage (= 5.2.4.2)
|
activestorage (= 5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
bundler (>= 1.3.0)
|
bundler (>= 1.3.0)
|
||||||
railties (= 5.2.4.2)
|
railties (= 5.2.4.3)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-controller-testing (1.0.4)
|
rails-controller-testing (1.0.5)
|
||||||
actionpack (>= 5.0.1.x)
|
actionpack (>= 5.0.1.rc1)
|
||||||
actionview (>= 5.0.1.x)
|
actionview (>= 5.0.1.rc1)
|
||||||
activesupport (>= 5.0.1.x)
|
activesupport (>= 5.0.1.rc1)
|
||||||
rails-dom-testing (2.0.3)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
@ -477,20 +476,20 @@ GEM
|
|||||||
railties (>= 5.0, < 6)
|
railties (>= 5.0, < 6)
|
||||||
rails-settings-cached (0.6.6)
|
rails-settings-cached (0.6.6)
|
||||||
rails (>= 4.2.0)
|
rails (>= 4.2.0)
|
||||||
railties (5.2.4.2)
|
railties (5.2.4.3)
|
||||||
actionpack (= 5.2.4.2)
|
actionpack (= 5.2.4.3)
|
||||||
activesupport (= 5.2.4.2)
|
activesupport (= 5.2.4.3)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
thor (>= 0.19.0, < 2.0)
|
thor (>= 0.19.0, < 2.0)
|
||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (13.0.1)
|
rake (13.0.1)
|
||||||
rdf (3.1.1)
|
rdf (3.1.4)
|
||||||
hamster (~> 3.0)
|
hamster (~> 3.0)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.4.0)
|
rdf-normalize (0.4.0)
|
||||||
rdf (~> 3.1)
|
rdf (~> 3.1)
|
||||||
redis (4.1.4)
|
redis (4.2.1)
|
||||||
redis-actionpack (5.2.0)
|
redis-actionpack (5.2.0)
|
||||||
actionpack (>= 5, < 7)
|
actionpack (>= 5, < 7)
|
||||||
redis-rack (>= 2.1.0, < 3)
|
redis-rack (>= 2.1.0, < 3)
|
||||||
@ -507,12 +506,12 @@ GEM
|
|||||||
redis-actionpack (>= 5.0, < 6)
|
redis-actionpack (>= 5.0, < 6)
|
||||||
redis-activesupport (>= 5.0, < 6)
|
redis-activesupport (>= 5.0, < 6)
|
||||||
redis-store (>= 1.2, < 2)
|
redis-store (>= 1.2, < 2)
|
||||||
redis-store (1.8.2)
|
redis-store (1.9.0)
|
||||||
redis (>= 4, < 5)
|
redis (>= 4, < 5)
|
||||||
regexp_parser (1.7.0)
|
regexp_parser (1.7.1)
|
||||||
request_store (1.5.0)
|
request_store (1.5.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
responders (3.0.0)
|
responders (3.0.1)
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
rexml (3.2.4)
|
rexml (3.2.4)
|
||||||
@ -530,7 +529,7 @@ GEM
|
|||||||
rspec-mocks (3.9.1)
|
rspec-mocks (3.9.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.9.0)
|
rspec-support (~> 3.9.0)
|
||||||
rspec-rails (4.0.0)
|
rspec-rails (4.0.1)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
railties (>= 4.2)
|
railties (>= 4.2)
|
||||||
@ -538,40 +537,42 @@ GEM
|
|||||||
rspec-expectations (~> 3.9)
|
rspec-expectations (~> 3.9)
|
||||||
rspec-mocks (~> 3.9)
|
rspec-mocks (~> 3.9)
|
||||||
rspec-support (~> 3.9)
|
rspec-support (~> 3.9)
|
||||||
rspec-sidekiq (3.0.3)
|
rspec-sidekiq (3.1.0)
|
||||||
rspec-core (~> 3.0, >= 3.0.0)
|
rspec-core (~> 3.0, >= 3.0.0)
|
||||||
sidekiq (>= 2.4.0)
|
sidekiq (>= 2.4.0)
|
||||||
rspec-support (3.9.3)
|
rspec-support (3.9.3)
|
||||||
rspec_junit_formatter (0.4.1)
|
rspec_junit_formatter (0.4.1)
|
||||||
rspec-core (>= 2, < 4, != 2.12.0)
|
rspec-core (>= 2, < 4, != 2.12.0)
|
||||||
rubocop (0.82.0)
|
rubocop (0.86.0)
|
||||||
jaro_winkler (~> 1.5.1)
|
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 2.7.0.1)
|
parser (>= 2.7.0.1)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
regexp_parser (>= 1.7)
|
||||||
rexml
|
rexml
|
||||||
|
rubocop-ast (>= 0.0.3, < 1.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 2.0)
|
unicode-display_width (>= 1.4.0, < 2.0)
|
||||||
rubocop-rails (2.5.2)
|
rubocop-ast (0.1.0)
|
||||||
activesupport
|
parser (>= 2.7.0.1)
|
||||||
|
rubocop-rails (2.6.0)
|
||||||
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 0.72.0)
|
rubocop (>= 0.82.0)
|
||||||
ruby-progressbar (1.10.1)
|
ruby-progressbar (1.10.1)
|
||||||
ruby-saml (1.11.0)
|
ruby-saml (1.11.0)
|
||||||
nokogiri (>= 1.5.10)
|
nokogiri (>= 1.5.10)
|
||||||
rufus-scheduler (3.6.0)
|
rufus-scheduler (3.6.0)
|
||||||
fugit (~> 1.1, >= 1.1.6)
|
fugit (~> 1.1, >= 1.1.6)
|
||||||
safe_yaml (1.0.5)
|
safe_yaml (1.0.5)
|
||||||
sanitize (5.1.0)
|
sanitize (5.2.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.8.0)
|
nokogiri (>= 1.8.0)
|
||||||
nokogumbo (~> 2.0)
|
nokogumbo (~> 2.0)
|
||||||
semantic_range (2.3.0)
|
semantic_range (2.3.0)
|
||||||
sidekiq (6.0.7)
|
sidekiq (6.1.0)
|
||||||
connection_pool (>= 2.2.2)
|
connection_pool (>= 2.2.2)
|
||||||
rack (~> 2.0)
|
rack (~> 2.0)
|
||||||
rack-protection (>= 2.0.0)
|
redis (>= 4.2.0)
|
||||||
redis (>= 4.1.0)
|
|
||||||
sidekiq-bulk (0.2.0)
|
sidekiq-bulk (0.2.0)
|
||||||
sidekiq
|
sidekiq
|
||||||
sidekiq-scheduler (3.0.1)
|
sidekiq-scheduler (3.0.1)
|
||||||
@ -581,7 +582,7 @@ GEM
|
|||||||
sidekiq (>= 3)
|
sidekiq (>= 3)
|
||||||
thwait
|
thwait
|
||||||
tilt (>= 1.4.0)
|
tilt (>= 1.4.0)
|
||||||
sidekiq-unique-jobs (6.0.21)
|
sidekiq-unique-jobs (6.0.22)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
sidekiq (>= 4.0, < 7.0)
|
sidekiq (>= 4.0, < 7.0)
|
||||||
thor (~> 0)
|
thor (~> 0)
|
||||||
@ -609,7 +610,7 @@ GEM
|
|||||||
stoplight (2.2.0)
|
stoplight (2.2.0)
|
||||||
streamio-ffmpeg (3.0.2)
|
streamio-ffmpeg (3.0.2)
|
||||||
multi_json (~> 1.8)
|
multi_json (~> 1.8)
|
||||||
strong_migrations (0.6.6)
|
strong_migrations (0.6.8)
|
||||||
activerecord (>= 5)
|
activerecord (>= 5)
|
||||||
temple (0.8.2)
|
temple (0.8.2)
|
||||||
terminal-table (1.8.0)
|
terminal-table (1.8.0)
|
||||||
@ -621,8 +622,6 @@ GEM
|
|||||||
thwait (0.1.0)
|
thwait (0.1.0)
|
||||||
tilt (2.0.10)
|
tilt (2.0.10)
|
||||||
tty-color (0.5.1)
|
tty-color (0.5.1)
|
||||||
tty-command (0.9.0)
|
|
||||||
pastel (~> 0.7.0)
|
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-prompt (0.21.0)
|
tty-prompt (0.21.0)
|
||||||
necromancer (~> 0.5.0)
|
necromancer (~> 0.5.0)
|
||||||
@ -632,7 +631,7 @@ GEM
|
|||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
tty-screen (~> 0.7)
|
tty-screen (~> 0.7)
|
||||||
wisper (~> 2.0.0)
|
wisper (~> 2.0.0)
|
||||||
tty-screen (0.7.1)
|
tty-screen (0.8.0)
|
||||||
twitter-text (1.14.7)
|
twitter-text (1.14.7)
|
||||||
unf (~> 0.1.0)
|
unf (~> 0.1.0)
|
||||||
tzinfo (1.2.7)
|
tzinfo (1.2.7)
|
||||||
@ -658,9 +657,9 @@ GEM
|
|||||||
webpush (0.3.8)
|
webpush (0.3.8)
|
||||||
hkdf (~> 0.2)
|
hkdf (~> 0.2)
|
||||||
jwt (~> 2.0)
|
jwt (~> 2.0)
|
||||||
websocket-driver (0.7.1)
|
websocket-driver (0.7.2)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.4)
|
websocket-extensions (0.1.5)
|
||||||
wisper (2.0.1)
|
wisper (2.0.1)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
@ -673,7 +672,7 @@ DEPENDENCIES
|
|||||||
active_record_query_trace (~> 1.7)
|
active_record_query_trace (~> 1.7)
|
||||||
addressable (~> 2.7)
|
addressable (~> 2.7)
|
||||||
annotate (~> 3.1)
|
annotate (~> 3.1)
|
||||||
aws-sdk-s3 (~> 1.64)
|
aws-sdk-s3 (~> 1.73)
|
||||||
better_errors (~> 2.7)
|
better_errors (~> 2.7)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
@ -681,16 +680,17 @@ DEPENDENCIES
|
|||||||
brakeman (~> 4.8)
|
brakeman (~> 4.8)
|
||||||
browser
|
browser
|
||||||
bullet (~> 6.1)
|
bullet (~> 6.1)
|
||||||
bundler-audit (~> 0.6)
|
bundler-audit (~> 0.7)
|
||||||
capistrano (~> 3.14)
|
capistrano (~> 3.14)
|
||||||
capistrano-rails (~> 1.4)
|
capistrano-rails (~> 1.5)
|
||||||
capistrano-rbenv (~> 2.1)
|
capistrano-rbenv (~> 2.1)
|
||||||
capistrano-yarn (~> 2.0)
|
capistrano-yarn (~> 2.0)
|
||||||
capybara (~> 3.32)
|
capybara (~> 3.33)
|
||||||
charlock_holmes (~> 0.7.7)
|
charlock_holmes (~> 0.7.7)
|
||||||
chewy (~> 5.1)
|
chewy (~> 5.1)
|
||||||
cld3 (~> 3.3.0)
|
cld3 (~> 3.3.0)
|
||||||
climate_control (~> 0.2)
|
climate_control (~> 0.2)
|
||||||
|
color_diff (~> 0.1)
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
connection_pool
|
connection_pool
|
||||||
devise (~> 4.7)
|
devise (~> 4.7)
|
||||||
@ -700,8 +700,9 @@ DEPENDENCIES
|
|||||||
doorkeeper (~> 5.4)
|
doorkeeper (~> 5.4)
|
||||||
dotenv-rails (~> 2.7)
|
dotenv-rails (~> 2.7)
|
||||||
e2mmap (~> 0.1.0)
|
e2mmap (~> 0.1.0)
|
||||||
|
ed25519 (~> 1.2)
|
||||||
fabrication (~> 2.21)
|
fabrication (~> 2.21)
|
||||||
faker (~> 2.11)
|
faker (~> 2.13)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
fastimage
|
fastimage
|
||||||
fog-core (<= 2.1.0)
|
fog-core (<= 2.1.0)
|
||||||
@ -715,7 +716,7 @@ DEPENDENCIES
|
|||||||
http (~> 4.4)
|
http (~> 4.4)
|
||||||
http_accept_language (~> 2.1)
|
http_accept_language (~> 2.1)
|
||||||
http_parser.rb (~> 0.6)!
|
http_parser.rb (~> 0.6)!
|
||||||
httplog (~> 1.4.2)
|
httplog (~> 1.4.3)
|
||||||
i18n-tasks (~> 0.9)
|
i18n-tasks (~> 0.9)
|
||||||
idn-ruby
|
idn-ruby
|
||||||
iso-639
|
iso-639
|
||||||
@ -743,10 +744,10 @@ DEPENDENCIES
|
|||||||
paperclip (~> 6.0)
|
paperclip (~> 6.0)
|
||||||
paperclip-av-transcoder (~> 0.6)
|
paperclip-av-transcoder (~> 0.6)
|
||||||
parallel (~> 1.19)
|
parallel (~> 1.19)
|
||||||
parallel_tests (~> 2.32)
|
parallel_tests (~> 3.0)
|
||||||
parslet
|
parslet
|
||||||
pg (~> 1.2)
|
pg (~> 1.2)
|
||||||
pghero (~> 2.4)
|
pghero (~> 2.5)
|
||||||
pkg-config (~> 1.4)
|
pkg-config (~> 1.4)
|
||||||
posix-spawn!
|
posix-spawn!
|
||||||
premailer-rails
|
premailer-rails
|
||||||
@ -755,25 +756,25 @@ DEPENDENCIES
|
|||||||
pry-rails (~> 0.3)
|
pry-rails (~> 0.3)
|
||||||
puma (~> 4.3)
|
puma (~> 4.3)
|
||||||
pundit (~> 2.1)
|
pundit (~> 2.1)
|
||||||
rack (~> 2.2.2)
|
rack (~> 2.2.3)
|
||||||
rack-attack (~> 6.3)
|
rack-attack (~> 6.3)
|
||||||
rack-cors (~> 1.1)
|
rack-cors (~> 1.1)
|
||||||
rails (~> 5.2.4.2)
|
rails (~> 5.2.4.3)
|
||||||
rails-controller-testing (~> 1.0)
|
rails-controller-testing (~> 1.0)
|
||||||
rails-i18n (~> 5.1)
|
rails-i18n (~> 5.1)
|
||||||
rails-settings-cached (~> 0.6)
|
rails-settings-cached (~> 0.6)
|
||||||
rdf-normalize (~> 0.4)
|
rdf-normalize (~> 0.4)
|
||||||
redis (~> 4.1)
|
redis (~> 4.2)
|
||||||
redis-namespace (~> 1.7)
|
redis-namespace (~> 1.7)
|
||||||
redis-rails (~> 5.0)
|
redis-rails (~> 5.0)
|
||||||
rqrcode (~> 1.1)
|
rqrcode (~> 1.1)
|
||||||
rspec-rails (~> 4.0)
|
rspec-rails (~> 4.0)
|
||||||
rspec-sidekiq (~> 3.0)
|
rspec-sidekiq (~> 3.1)
|
||||||
rspec_junit_formatter (~> 0.4)
|
rspec_junit_formatter (~> 0.4)
|
||||||
rubocop (~> 0.82)
|
rubocop (~> 0.86)
|
||||||
rubocop-rails (~> 2.5)
|
rubocop-rails (~> 2.6)
|
||||||
ruby-progressbar (~> 1.10)
|
ruby-progressbar (~> 1.10)
|
||||||
sanitize (~> 5.1)
|
sanitize (~> 5.2)
|
||||||
sidekiq (~> 6.0)
|
sidekiq (~> 6.0)
|
||||||
sidekiq-bulk (~> 0.2.0)
|
sidekiq-bulk (~> 0.2.0)
|
||||||
sidekiq-scheduler (~> 3.0)
|
sidekiq-scheduler (~> 3.0)
|
||||||
@ -789,7 +790,6 @@ DEPENDENCIES
|
|||||||
strong_migrations (~> 0.6)
|
strong_migrations (~> 0.6)
|
||||||
thor (~> 0.20)
|
thor (~> 0.20)
|
||||||
thwait (~> 0.1.0)
|
thwait (~> 0.1.0)
|
||||||
tty-command (~> 0.9)
|
|
||||||
tty-prompt (~> 0.21)
|
tty-prompt (~> 0.21)
|
||||||
twitter-text (~> 1.14)
|
twitter-text (~> 1.14)
|
||||||
tzinfo-data (~> 1.2020)
|
tzinfo-data (~> 1.2020)
|
||||||
|
12
SECURITY.md
Normal file
12
SECURITY.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| 3.1.x | :white_check_mark: |
|
||||||
|
| < 3.1 | :x: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
hello@joinmastodon.org
|
3
app.json
3
app.json
@ -88,9 +88,6 @@
|
|||||||
{
|
{
|
||||||
"url": "https://github.com/heroku/heroku-buildpack-apt"
|
"url": "https://github.com/heroku/heroku-buildpack-apt"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"url": "heroku/nodejs"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"url": "heroku/ruby"
|
"url": "heroku/ruby"
|
||||||
}
|
}
|
||||||
|
@ -31,9 +31,9 @@ class StatusesIndex < Chewy::Index
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do
|
define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) do
|
||||||
crutch :mentions do |collection|
|
crutch :mentions do |collection|
|
||||||
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
|
||||||
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class AccountsController < ApplicationController
|
class AccountsController < ApplicationController
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
|
PAGE_SIZE_MAX = 200
|
||||||
|
|
||||||
include AccountControllerConcern
|
include AccountControllerConcern
|
||||||
include SignatureAuthentication
|
include SignatureAuthentication
|
||||||
@ -10,7 +11,7 @@ class AccountsController < ApplicationController
|
|||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
|
||||||
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
|
skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def show
|
def show
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
@ -44,7 +45,8 @@ class AccountsController < ApplicationController
|
|||||||
format.rss do
|
format.rss do
|
||||||
expires_in 1.minute, public: true
|
expires_in 1.minute, public: true
|
||||||
|
|
||||||
@statuses = filtered_statuses.without_reblogs.without_local_only.limit(PAGE_SIZE)
|
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
|
||||||
|
@statuses = filtered_statuses.without_reblogs.without_local_only.limit(limit)
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
|
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
|
||||||
end
|
end
|
||||||
|
21
app/controllers/activitypub/claims_controller.rb
Normal file
21
app/controllers/activitypub/claims_controller.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ClaimsController < ActivityPub::BaseController
|
||||||
|
include SignatureVerification
|
||||||
|
include AccountOwnedConcern
|
||||||
|
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
|
||||||
|
before_action :require_signature!
|
||||||
|
before_action :set_claim_result
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_claim_result
|
||||||
|
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
|
||||||
|
end
|
||||||
|
end
|
@ -5,8 +5,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
|||||||
include AccountOwnedConcern
|
include AccountOwnedConcern
|
||||||
|
|
||||||
before_action :require_signature!, if: :authorized_fetch_mode?
|
before_action :require_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_items
|
||||||
before_action :set_size
|
before_action :set_size
|
||||||
before_action :set_statuses
|
before_action :set_type
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@ -16,40 +17,53 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_statuses
|
def set_items
|
||||||
@statuses = scope_for_collection
|
|
||||||
@statuses = cache_collection(@statuses, Status)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_size
|
|
||||||
case params[:id]
|
case params[:id]
|
||||||
when 'featured'
|
when 'featured'
|
||||||
@size = @account.pinned_statuses.count
|
@items = begin
|
||||||
|
# Because in public fetch mode we cache the response, there would be no
|
||||||
|
# benefit from performing the check below, since a blocked account or domain
|
||||||
|
# would likely be served the cache from the reverse proxy anyway
|
||||||
|
|
||||||
|
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
cache_collection(@account.pinned_statuses, Status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
when 'devices'
|
||||||
|
@items = @account.devices
|
||||||
else
|
else
|
||||||
not_found
|
not_found
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def scope_for_collection
|
def set_size
|
||||||
|
case params[:id]
|
||||||
|
when 'featured', 'devices'
|
||||||
|
@size = @items.size
|
||||||
|
else
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_type
|
||||||
case params[:id]
|
case params[:id]
|
||||||
when 'featured'
|
when 'featured'
|
||||||
# Because in public fetch mode we cache the response, there would be no
|
@type = :ordered
|
||||||
# benefit from performing the check below, since a blocked account or domain
|
when 'devices'
|
||||||
# would likely be served the cache from the reverse proxy anyway
|
@type = :unordered
|
||||||
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
|
|
||||||
Status.none
|
|
||||||
else
|
else
|
||||||
@account.pinned_statuses
|
not_found
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_collection_url(@account, params[:id]),
|
id: account_collection_url(@account, params[:id]),
|
||||||
type: :ordered,
|
type: @type,
|
||||||
size: @size,
|
size: @size,
|
||||||
items: @statuses
|
items: @items
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -33,6 +33,8 @@ module Admin
|
|||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
|
||||||
ensure
|
ensure
|
||||||
redirect_to admin_custom_emojis_path(filter_params)
|
redirect_to admin_custom_emojis_path(filter_params)
|
||||||
end
|
end
|
||||||
|
@ -7,7 +7,7 @@ class Api::BaseController < ApplicationController
|
|||||||
include RateLimitHeaders
|
include RateLimitHeaders
|
||||||
|
|
||||||
skip_before_action :store_current_location
|
skip_before_action :store_current_location
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
|
before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access?
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Accounts::NotesController < Api::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_account
|
||||||
|
|
||||||
|
def create
|
||||||
|
if params[:comment].blank?
|
||||||
|
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
|
||||||
|
else
|
||||||
|
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
|
||||||
|
@note.comment = params[:comment]
|
||||||
|
@note.save! if @note.changed?
|
||||||
|
end
|
||||||
|
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_account
|
||||||
|
@account = Account.find(params[:account_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def relationships_presenter
|
||||||
|
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
||||||
|
end
|
||||||
|
end
|
30
app/controllers/api/v1/crypto/deliveries_controller.rb
Normal file
30
app/controllers/api/v1/crypto/deliveries_controller.rb
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::DeliveriesController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_current_device
|
||||||
|
|
||||||
|
def create
|
||||||
|
devices.each do |device_params|
|
||||||
|
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_current_device
|
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:device)
|
||||||
|
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
|
||||||
|
end
|
||||||
|
|
||||||
|
def devices
|
||||||
|
Array(resource_params[:device])
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
|
||||||
|
LIMIT = 80
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_current_device
|
||||||
|
|
||||||
|
before_action :set_encrypted_messages, only: :index
|
||||||
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear
|
||||||
|
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_current_device
|
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_encrypted_messages
|
||||||
|
@encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_pagination_headers
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_max_id
|
||||||
|
@encrypted_messages.last.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_since_id
|
||||||
|
@encrypted_messages.first.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def records_continue?
|
||||||
|
@encrypted_messages.size == limit_param(LIMIT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_params(core_params)
|
||||||
|
params.slice(:limit).permit(:limit).merge(core_params)
|
||||||
|
end
|
||||||
|
end
|
25
app/controllers/api/v1/crypto/keys/claims_controller.rb
Normal file
25
app/controllers/api/v1/crypto/keys/claims_controller.rb
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_claim_results
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_claim_results
|
||||||
|
@claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.permit(device: [:account_id, :device_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def devices
|
||||||
|
Array(resource_params[:device])
|
||||||
|
end
|
||||||
|
end
|
17
app/controllers/api/v1/crypto/keys/counts_controller.rb
Normal file
17
app/controllers/api/v1/crypto/keys/counts_controller.rb
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::CountsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_current_device
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: { one_time_keys: @current_device.one_time_keys.count }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_current_device
|
||||||
|
@current_device = Device.find_by!(access_token: doorkeeper_token)
|
||||||
|
end
|
||||||
|
end
|
26
app/controllers/api/v1/crypto/keys/queries_controller.rb
Normal file
26
app/controllers/api/v1/crypto/keys/queries_controller.rb
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_accounts
|
||||||
|
before_action :set_query_results
|
||||||
|
|
||||||
|
def create
|
||||||
|
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_accounts
|
||||||
|
@accounts = Account.where(id: account_ids).includes(:devices)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_query_results
|
||||||
|
@query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_ids
|
||||||
|
Array(params[:id]).map(&:to_i)
|
||||||
|
end
|
||||||
|
end
|
29
app/controllers/api/v1/crypto/keys/uploads_controller.rb
Normal file
29
app/controllers/api/v1/crypto/keys/uploads_controller.rb
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :crypto }
|
||||||
|
before_action :require_user!
|
||||||
|
|
||||||
|
def create
|
||||||
|
device = Device.find_or_initialize_by(access_token: doorkeeper_token)
|
||||||
|
|
||||||
|
device.transaction do
|
||||||
|
device.account = current_account
|
||||||
|
device.update!(resource_params[:device])
|
||||||
|
|
||||||
|
if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
|
||||||
|
resource_params[:one_time_keys].each do |one_time_key_params|
|
||||||
|
device.one_time_keys.create!(one_time_key_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: device, serializer: REST::Keys::DeviceSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
|
||||||
|
end
|
||||||
|
end
|
@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def media_attachment_params
|
def media_attachment_params
|
||||||
params.permit(:file, :description, :focus)
|
params.permit(:file, :thumbnail, :description, :focus)
|
||||||
end
|
end
|
||||||
|
|
||||||
def file_type_error
|
def file_type_error
|
||||||
|
@ -5,7 +5,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
before_action :set_reblog
|
before_action :set_reblog, only: [:create]
|
||||||
|
|
||||||
override_rate_limit_headers :create, family: :statuses
|
override_rate_limit_headers :create, family: :statuses
|
||||||
|
|
||||||
@ -16,15 +16,21 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@status = current_account.statuses.find_by(reblog_of_id: @reblog.id)
|
@status = current_account.statuses.find_by(reblog_of_id: params[:status_id])
|
||||||
|
|
||||||
if @status
|
if @status
|
||||||
authorize @status, :unreblog?
|
authorize @status, :unreblog?
|
||||||
@status.discard
|
@status.discard
|
||||||
RemovalWorker.perform_async(@status.id)
|
RemovalWorker.perform_async(@status.id)
|
||||||
|
@reblog = @status.reblog
|
||||||
|
else
|
||||||
|
@reblog = Status.find(params[:status_id])
|
||||||
|
authorize @reblog, :show?
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false })
|
render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false })
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -58,6 +58,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||||||
|
|
||||||
@status.discard
|
@status.discard
|
||||||
RemovalWorker.perform_async(@status.id, redraft: true)
|
RemovalWorker.perform_async(@status.id, redraft: true)
|
||||||
|
@status.account.statuses_count = @status.account.statuses_count - 1
|
||||||
|
|
||||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||||
end
|
end
|
||||||
|
@ -55,7 +55,7 @@ class ApplicationController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def store_current_location
|
def store_current_location
|
||||||
store_location_for(:user, request.url) unless request.format == :json
|
store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_admin!
|
def require_admin!
|
||||||
|
@ -8,7 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
skip_before_action :require_no_authentication, only: [:create]
|
skip_before_action :require_no_authentication, only: [:create]
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
|
include TwoFactorAuthenticationConcern
|
||||||
|
include SignInTokenAuthenticationConcern
|
||||||
|
|
||||||
before_action :set_instance_presenter, only: [:new]
|
before_action :set_instance_presenter, only: [:new]
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
@ -39,17 +40,18 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
protected
|
protected
|
||||||
|
|
||||||
def find_user
|
def find_user
|
||||||
if session[:otp_user_id]
|
if session[:attempt_user_id]
|
||||||
User.find(session[:otp_user_id])
|
User.find(session[:attempt_user_id])
|
||||||
else
|
else
|
||||||
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
|
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
|
||||||
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
|
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
|
||||||
user ||= User.find_for_authentication(email: user_params[:email])
|
user ||= User.find_for_authentication(email: user_params[:email])
|
||||||
|
user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email, :password, :otp_attempt)
|
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_sign_in_path_for(resource)
|
def after_sign_in_path_for(resource)
|
||||||
@ -70,47 +72,6 @@ class Auth::SessionsController < Devise::SessionsController
|
|||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def two_factor_enabled?
|
|
||||||
find_user&.otp_required_for_login?
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid_otp_attempt?(user)
|
|
||||||
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
|
||||||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
|
||||||
rescue OpenSSL::Cipher::CipherError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate_with_two_factor
|
|
||||||
user = self.resource = find_user
|
|
||||||
|
|
||||||
if user_params[:otp_attempt].present? && session[:otp_user_id]
|
|
||||||
authenticate_with_two_factor_via_otp(user)
|
|
||||||
elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
|
|
||||||
# If encrypted_password is blank, we got the user from LDAP or PAM,
|
|
||||||
# so credentials are already valid
|
|
||||||
|
|
||||||
prompt_for_two_factor(user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate_with_two_factor_via_otp(user)
|
|
||||||
if valid_otp_attempt?(user)
|
|
||||||
session.delete(:otp_user_id)
|
|
||||||
remember_me(user)
|
|
||||||
sign_in(user)
|
|
||||||
else
|
|
||||||
flash.now[:alert] = I18n.t('users.invalid_otp_token')
|
|
||||||
prompt_for_two_factor(user)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def prompt_for_two_factor(user)
|
|
||||||
session[:otp_user_id] = user.id
|
|
||||||
@body_classes = 'lighter'
|
|
||||||
render :two_factor
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_no_authentication
|
def require_no_authentication
|
||||||
super
|
super
|
||||||
# Delete flash message that isn't entirely useful and may be confusing in
|
# Delete flash message that isn't entirely useful and may be confusing in
|
||||||
|
@ -7,8 +7,6 @@ module Localized
|
|||||||
around_action :set_locale
|
around_action :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_locale
|
def set_locale
|
||||||
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
|
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
|
||||||
locale ||= session[:locale] ||= default_locale
|
locale ||= session[:locale] ||= default_locale
|
||||||
@ -19,6 +17,8 @@ module Localized
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
def default_locale
|
def default_locale
|
||||||
if ENV['DEFAULT_LOCALE'].present?
|
if ENV['DEFAULT_LOCALE'].present?
|
||||||
I18n.default_locale
|
I18n.default_locale
|
||||||
|
@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SignInTokenAuthenticationConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign_in_token_required?
|
||||||
|
find_user&.suspicious_sign_in?(request.remote_ip)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_sign_in_token_attempt?(user)
|
||||||
|
Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_with_sign_in_token
|
||||||
|
user = self.resource = find_user
|
||||||
|
|
||||||
|
if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
|
||||||
|
authenticate_with_sign_in_token_attempt(user)
|
||||||
|
elsif user.present? && user.external_or_valid_password?(user_params[:password])
|
||||||
|
prompt_for_sign_in_token(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_with_sign_in_token_attempt(user)
|
||||||
|
if valid_sign_in_token_attempt?(user)
|
||||||
|
session.delete(:attempt_user_id)
|
||||||
|
remember_me(user)
|
||||||
|
sign_in(user)
|
||||||
|
else
|
||||||
|
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
|
||||||
|
prompt_for_sign_in_token(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prompt_for_sign_in_token(user)
|
||||||
|
if user.sign_in_token_expired?
|
||||||
|
user.generate_sign_in_token && user.save
|
||||||
|
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
|
||||||
|
end
|
||||||
|
|
||||||
|
set_locale do
|
||||||
|
session[:attempt_user_id] = user.id
|
||||||
|
@body_classes = 'lighter'
|
||||||
|
render :sign_in_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,49 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module TwoFactorAuthenticationConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
|
||||||
|
end
|
||||||
|
|
||||||
|
def two_factor_enabled?
|
||||||
|
find_user&.otp_required_for_login?
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_otp_attempt?(user)
|
||||||
|
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
||||||
|
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
||||||
|
rescue OpenSSL::Cipher::CipherError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_with_two_factor
|
||||||
|
user = self.resource = find_user
|
||||||
|
|
||||||
|
if user_params[:otp_attempt].present? && session[:attempt_user_id]
|
||||||
|
authenticate_with_two_factor_attempt(user)
|
||||||
|
elsif user.present? && user.external_or_valid_password?(user_params[:password])
|
||||||
|
prompt_for_two_factor(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate_with_two_factor_attempt(user)
|
||||||
|
if valid_otp_attempt?(user)
|
||||||
|
session.delete(:attempt_user_id)
|
||||||
|
remember_me(user)
|
||||||
|
sign_in(user)
|
||||||
|
else
|
||||||
|
flash.now[:alert] = I18n.t('users.invalid_otp_token')
|
||||||
|
prompt_for_two_factor(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prompt_for_two_factor(user)
|
||||||
|
set_locale do
|
||||||
|
session[:attempt_user_id] = user.id
|
||||||
|
@body_classes = 'lighter'
|
||||||
|
render :two_factor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -9,7 +9,7 @@ class DirectoriesController < ApplicationController
|
|||||||
before_action :set_tag, only: :show
|
before_action :set_tag, only: :show
|
||||||
before_action :set_accounts
|
before_action :set_accounts
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render :index
|
render :index
|
||||||
|
@ -8,7 +8,7 @@ class FollowerAccountsController < ApplicationController
|
|||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def index
|
def index
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
|
@ -8,7 +8,7 @@ class FollowingAccountsController < ApplicationController
|
|||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
|
||||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def index
|
def index
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
|
@ -4,7 +4,7 @@ class MediaController < ApplicationController
|
|||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
skip_before_action :store_current_location
|
skip_before_action :store_current_location
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
before_action :authenticate_user!, if: :whitelist_mode?
|
before_action :authenticate_user!, if: :whitelist_mode?
|
||||||
before_action :set_media_attachment
|
before_action :set_media_attachment
|
||||||
|
@ -31,7 +31,7 @@ class MediaProxyController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def redownload!
|
def redownload!
|
||||||
@media_attachment.file_remote_url = @media_attachment.remote_url
|
@media_attachment.download_file!
|
||||||
@media_attachment.created_at = Time.now.utc
|
@media_attachment.created_at = Time.now.utc
|
||||||
@media_attachment.save!
|
@media_attachment.save!
|
||||||
end
|
end
|
||||||
|
@ -10,7 +10,7 @@ class RemoteInteractionController < ApplicationController
|
|||||||
before_action :set_status
|
before_action :set_status
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@remote_follow = RemoteFollow.new(session_params)
|
@remote_follow = RemoteFollow.new(session_params)
|
||||||
|
@ -18,7 +18,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
|
|||||||
if @redirect.valid_with_challenge?(current_user)
|
if @redirect.valid_with_challenge?(current_user)
|
||||||
current_account.update!(moved_to_account: @redirect.target_account)
|
current_account.update!(moved_to_account: @redirect.target_account)
|
||||||
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
|
||||||
redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
|
redirect_to settings_migration_path, notice: I18n.t('migrations.redirected_msg', acct: current_account.moved_to_account.acct)
|
||||||
else
|
else
|
||||||
render :new
|
render :new
|
||||||
end
|
end
|
||||||
|
@ -7,13 +7,8 @@ module Settings
|
|||||||
before_action :set_picture
|
before_action :set_picture
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if valid_picture
|
if valid_picture?
|
||||||
account_params = {
|
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
|
||||||
@picture => nil,
|
|
||||||
(@picture + '_remote_url') => nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
|
|
||||||
redirect_to settings_profile_path, notice: msg, status: 303
|
redirect_to settings_profile_path, notice: msg, status: 303
|
||||||
else
|
else
|
||||||
bad_request
|
bad_request
|
||||||
@ -30,8 +25,8 @@ module Settings
|
|||||||
@picture = params[:id]
|
@picture = params[:id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_picture
|
def valid_picture?
|
||||||
@picture == 'avatar' || @picture == 'header'
|
%w(avatar header).include?(@picture)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -19,7 +19,7 @@ class StatusesController < ApplicationController
|
|||||||
before_action :set_autoplay, only: :embed
|
before_action :set_autoplay, only: :embed
|
||||||
|
|
||||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||||
skip_before_action :require_functional!, only: [:show, :embed]
|
skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
|
||||||
|
|
||||||
content_security_policy only: :embed do |p|
|
content_security_policy only: :embed do |p|
|
||||||
p.frame_ancestors(false)
|
p.frame_ancestors(false)
|
||||||
@ -42,7 +42,7 @@ class StatusesController < ApplicationController
|
|||||||
|
|
||||||
def activity
|
def activity
|
||||||
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
|
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
|
||||||
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
|
||||||
end
|
end
|
||||||
|
|
||||||
def embed
|
def embed
|
||||||
|
@ -4,6 +4,7 @@ class TagsController < ApplicationController
|
|||||||
include SignatureVerification
|
include SignatureVerification
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
|
PAGE_SIZE_MAX = 200
|
||||||
|
|
||||||
layout 'public'
|
layout 'public'
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ class TagsController < ApplicationController
|
|||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
before_action :set_instance_presenter
|
before_action :set_instance_presenter
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!, unless: :whitelist_mode?
|
||||||
|
|
||||||
def show
|
def show
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
@ -25,7 +26,8 @@ class TagsController < ApplicationController
|
|||||||
format.rss do
|
format.rss do
|
||||||
expires_in 0, public: true
|
expires_in 0, public: true
|
||||||
|
|
||||||
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
|
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
|
||||||
|
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(limit)
|
||||||
@statuses = cache_collection(@statuses, Status)
|
@statuses = cache_collection(@statuses, Status)
|
||||||
|
|
||||||
render xml: RSS::TagSerializer.render(@tag, @statuses)
|
render xml: RSS::TagSerializer.render(@tag, @statuses)
|
||||||
|
@ -8,7 +8,8 @@ module WellKnown
|
|||||||
before_action :set_account
|
before_action :set_account
|
||||||
before_action :check_account_suspension
|
before_action :check_account_suspension
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordNotFound, ActionController::ParameterMissing, with: :not_found
|
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||||
|
rescue_from ActionController::ParameterMissing, WebfingerResource::InvalidRequest, with: :bad_request
|
||||||
|
|
||||||
def show
|
def show
|
||||||
expires_in 3.days, public: true
|
expires_in 3.days, public: true
|
||||||
@ -37,6 +38,10 @@ module WellKnown
|
|||||||
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
expires_in(3.minutes, public: true) && gone if @account.suspended?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bad_request
|
||||||
|
head 400
|
||||||
|
end
|
||||||
|
|
||||||
def not_found
|
def not_found
|
||||||
head 404
|
head 404
|
||||||
end
|
end
|
||||||
|
@ -77,6 +77,18 @@ module ApplicationHelper
|
|||||||
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
|
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def visibility_icon(status)
|
||||||
|
if status.public_visibility?
|
||||||
|
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
|
||||||
|
elsif status.unlisted_visibility?
|
||||||
|
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
|
||||||
|
elsif status.private_visibility? || status.limited_visibility?
|
||||||
|
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
|
||||||
|
elsif status.direct_visibility?
|
||||||
|
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def custom_emoji_tag(custom_emoji, animate = true)
|
def custom_emoji_tag(custom_emoji, animate = true)
|
||||||
if animate
|
if animate
|
||||||
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
|
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
|
||||||
@ -136,6 +148,11 @@ module ApplicationHelper
|
|||||||
text: [params[:title], params[:text], params[:url]].compact.join(' '),
|
text: [params[:title], params[:text], params[:url]].compact.join(' '),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
permit_visibilities = %w(public unlisted private direct)
|
||||||
|
default_privacy = current_account&.user&.setting_default_privacy
|
||||||
|
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
|
||||||
|
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]
|
||||||
|
|
||||||
if user_signed_in?
|
if user_signed_in?
|
||||||
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
|
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
|
||||||
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)
|
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)
|
||||||
|
@ -15,11 +15,13 @@ module StatusesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def media_summary(status)
|
def media_summary(status)
|
||||||
attachments = { image: 0, video: 0 }
|
attachments = { image: 0, video: 0, audio: 0 }
|
||||||
|
|
||||||
status.media_attachments.each do |media|
|
status.media_attachments.each do |media|
|
||||||
if media.video?
|
if media.video?
|
||||||
attachments[:video] += 1
|
attachments[:video] += 1
|
||||||
|
elsif media.audio?
|
||||||
|
attachments[:audio] += 1
|
||||||
else
|
else
|
||||||
attachments[:image] += 1
|
attachments[:image] += 1
|
||||||
end
|
end
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Monkey-patch on monkey-patch.
|
||||||
|
# Because it conflicts with the request.rb patch.
|
||||||
|
class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
|
||||||
|
def connect(socket_class, host, port, nodelay = false)
|
||||||
|
::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
|
||||||
|
@socket = socket_class.open(host, port)
|
||||||
|
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
module WebfingerHelper
|
module WebfingerHelper
|
||||||
def webfinger!(uri)
|
def webfinger!(uri)
|
||||||
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
|
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
|
||||||
@ -12,6 +23,14 @@ module WebfingerHelper
|
|||||||
headers: {
|
headers: {
|
||||||
'User-Agent': Mastodon::Version.user_agent,
|
'User-Agent': Mastodon::Version.user_agent,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
timeout_class: HTTP::Timeout::PerOperationOriginal,
|
||||||
|
|
||||||
|
timeout_options: {
|
||||||
|
write_timeout: 10,
|
||||||
|
connect_timeout: 5,
|
||||||
|
read_timeout: 10,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
|
Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
|
||||||
|
37
app/javascript/mastodon/actions/account_notes.js
Normal file
37
app/javascript/mastodon/actions/account_notes.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||||
|
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||||
|
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||||
|
|
||||||
|
export function submitAccountNote(id, value) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(submitAccountNoteRequest());
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||||
|
comment: value,
|
||||||
|
}).then(response => {
|
||||||
|
dispatch(submitAccountNoteSuccess(response.data));
|
||||||
|
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function submitAccountNoteRequest() {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function submitAccountNoteSuccess(relationship) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||||
|
relationship,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function submitAccountNoteFail(error) {
|
||||||
|
return {
|
||||||
|
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
|||||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||||
|
|
||||||
|
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
|
||||||
|
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
|
||||||
|
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
|
||||||
|
export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
|
||||||
|
|
||||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
||||||
@ -160,7 +165,6 @@ export function submitCompose(routerHistory) {
|
|||||||
|
|
||||||
// To make the app more responsive, immediately push the status
|
// To make the app more responsive, immediately push the status
|
||||||
// into the columns
|
// into the columns
|
||||||
|
|
||||||
const insertIfOnline = timelineId => {
|
const insertIfOnline = timelineId => {
|
||||||
const timeline = getState().getIn(['timelines', timelineId]);
|
const timeline = getState().getIn(['timelines', timelineId]);
|
||||||
|
|
||||||
@ -176,6 +180,7 @@ export function submitCompose(routerHistory) {
|
|||||||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
||||||
insertIfOnline('community');
|
insertIfOnline('community');
|
||||||
insertIfOnline('public');
|
insertIfOnline('public');
|
||||||
|
insertIfOnline(`account:${response.data.account.id}`);
|
||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(submitComposeFail(error));
|
dispatch(submitComposeFail(error));
|
||||||
@ -262,6 +267,49 @@ export function uploadCompose(files) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
|
||||||
|
dispatch(uploadThumbnailRequest());
|
||||||
|
|
||||||
|
const total = file.size;
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
data.append('thumbnail', file);
|
||||||
|
|
||||||
|
api(getState).put(`/api/v1/media/${id}`, data, {
|
||||||
|
onUploadProgress: ({ loaded }) => {
|
||||||
|
dispatch(uploadThumbnailProgress(loaded, total));
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
dispatch(uploadThumbnailSuccess(data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(uploadThumbnailFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadThumbnailRequest = () => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailProgress = (loaded, total) => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_PROGRESS,
|
||||||
|
loaded,
|
||||||
|
total,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailSuccess = media => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_SUCCESS,
|
||||||
|
media,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailFail = error => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_FAIL,
|
||||||
|
error,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
export function changeUploadCompose(id, params) {
|
export function changeUploadCompose(id, params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(changeUploadComposeRequest());
|
dispatch(changeUploadComposeRequest());
|
||||||
@ -280,6 +328,7 @@ export function changeUploadComposeRequest() {
|
|||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function changeUploadComposeSuccess(media) {
|
export function changeUploadComposeSuccess(media) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
|
export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
|
||||||
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
|
export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
|
||||||
|
|
||||||
export function openDropdownMenu(id, placement, keyboard) {
|
export function openDropdownMenu(id, placement, keyboard, scroll_key) {
|
||||||
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard };
|
return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeDropdownMenu(id) {
|
export function closeDropdownMenu(id) {
|
||||||
|
@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
|
|||||||
|
|
||||||
export function searchTextFromRawStatus (status) {
|
export function searchTextFromRawStatus (status) {
|
||||||
const spoilerText = status.spoiler_text || '';
|
const spoilerText = status.spoiler_text || '';
|
||||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
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');
|
||||||
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,30 +1,102 @@
|
|||||||
export const submitMarkers = () => (dispatch, getState) => {
|
import api from '../api';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import compareId from '../compare_id';
|
||||||
|
import { showAlertForError } from './alerts';
|
||||||
|
|
||||||
|
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
|
||||||
|
|
||||||
|
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
||||||
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
const accessToken = getState().getIn(['meta', 'access_token'], '');
|
||||||
const params = {};
|
const params = _buildParams(getState());
|
||||||
|
|
||||||
const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]);
|
|
||||||
const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
|
|
||||||
|
|
||||||
if (lastHomeId) {
|
|
||||||
params.home = {
|
|
||||||
last_read_id: lastHomeId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastNotificationId) {
|
|
||||||
params.notifications = {
|
|
||||||
last_read_id: lastNotificationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(params).length === 0) {
|
if (Object.keys(params).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Fetch API allows us to perform requests that will be carried out
|
||||||
|
// after the page closes. But that only works if the `keepalive` attribute
|
||||||
|
// is supported.
|
||||||
|
if (window.fetch && 'keepalive' in new Request('')) {
|
||||||
|
fetch('/api/v1/markers', {
|
||||||
|
keepalive: true,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (navigator && navigator.sendBeacon) {
|
||||||
|
// Failing that, we can use sendBeacon, but we have to encode the data as
|
||||||
|
// FormData for DoorKeeper to recognize the token.
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('bearer_token', accessToken);
|
||||||
|
for (const [id, value] of Object.entries(params)) {
|
||||||
|
formData.append(`${id}[last_read_id]`, value.last_read_id);
|
||||||
|
}
|
||||||
|
if (navigator.sendBeacon('/api/v1/markers', formData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither Fetch nor sendBeacon worked, try to perform a synchronous
|
||||||
|
// request.
|
||||||
|
try {
|
||||||
const client = new XMLHttpRequest();
|
const client = new XMLHttpRequest();
|
||||||
|
|
||||||
client.open('POST', '/api/v1/markers', false);
|
client.open('POST', '/api/v1/markers', false);
|
||||||
client.setRequestHeader('Content-Type', 'application/json');
|
client.setRequestHeader('Content-Type', 'application/json');
|
||||||
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
|
||||||
client.send(JSON.stringify(params));
|
client.SUBMIT(JSON.stringify(params));
|
||||||
|
} catch (e) {
|
||||||
|
// Do not make the BeforeUnload handler error out
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _buildParams = (state) => {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]);
|
||||||
|
const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
|
||||||
|
|
||||||
|
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
|
||||||
|
params.home = {
|
||||||
|
last_read_id: lastHomeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
|
||||||
|
params.notifications = {
|
||||||
|
last_read_id: lastNotificationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSubmitMarkers = debounce((dispatch, getState) => {
|
||||||
|
const params = _buildParams(getState());
|
||||||
|
|
||||||
|
if (Object.keys(params).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api().post('/api/v1/markers', params).then(() => {
|
||||||
|
dispatch(submitMarkersSuccess(params));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(showAlertForError(error));
|
||||||
|
});
|
||||||
|
}, 300000, { leading: true, trailing: true });
|
||||||
|
|
||||||
|
export function submitMarkersSuccess({ home, notifications }) {
|
||||||
|
return {
|
||||||
|
type: MARKERS_SUBMIT_SUCCESS,
|
||||||
|
home: (home || {}).last_read_id,
|
||||||
|
notifications: (notifications || {}).last_read_id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function submitMarkers() {
|
||||||
|
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
importFetchedStatus,
|
importFetchedStatus,
|
||||||
importFetchedStatuses,
|
importFetchedStatuses,
|
||||||
} from './importer';
|
} from './importer';
|
||||||
|
import { submitMarkers } from './markers';
|
||||||
import { saveSettings } from './settings';
|
import { saveSettings } from './settings';
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
@ -70,6 +71,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||||||
filtered = regex && regex.test(searchIndex);
|
filtered = regex && regex.test(searchIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispatch(submitMarkers());
|
||||||
|
|
||||||
if (showInColumn) {
|
if (showInColumn) {
|
||||||
dispatch(importFetchedAccount(notification.account));
|
dispatch(importFetchedAccount(notification.account));
|
||||||
|
|
||||||
@ -157,6 +160,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
|
|||||||
|
|
||||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||||
fetchRelatedRelationships(dispatch, response.data);
|
fetchRelatedRelationships(dispatch, response.data);
|
||||||
|
dispatch(submitMarkers());
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
|
@ -3,7 +3,7 @@ import openDB from '../storage/db';
|
|||||||
import { evictStatus } from '../storage/modifier';
|
import { evictStatus } from '../storage/modifier';
|
||||||
|
|
||||||
import { deleteFromTimelines } from './timelines';
|
import { deleteFromTimelines } from './timelines';
|
||||||
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
|
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus, importFetchedAccount } from './importer';
|
||||||
import { ensureComposeIsVisible } from './compose';
|
import { ensureComposeIsVisible } from './compose';
|
||||||
|
|
||||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||||
@ -155,6 +155,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
|
|||||||
evictStatus(id);
|
evictStatus(id);
|
||||||
dispatch(deleteStatusSuccess(id));
|
dispatch(deleteStatusSuccess(id));
|
||||||
dispatch(deleteFromTimelines(id));
|
dispatch(deleteFromTimelines(id));
|
||||||
|
dispatch(importFetchedAccount(response.data.account));
|
||||||
|
|
||||||
if (withRedraft) {
|
if (withRedraft) {
|
||||||
dispatch(redraft(status, response.data.text));
|
dispatch(redraft(status, response.data.text));
|
||||||
|
@ -74,6 +74,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
|||||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||||
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
||||||
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
|
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
|
||||||
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
|
export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
|
||||||
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
||||||
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
|
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
|
import { submitMarkers } from './markers';
|
||||||
import api, { getLinks } from 'mastodon/api';
|
import api, { getLinks } from 'mastodon/api';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import compareId from 'mastodon/compare_id';
|
import compareId from 'mastodon/compare_id';
|
||||||
@ -36,6 +37,10 @@ export function updateTimeline(timeline, status, accept) {
|
|||||||
status,
|
status,
|
||||||
usePendingItems: preferPendingItems,
|
usePendingItems: preferPendingItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (timeline === 'home') {
|
||||||
|
dispatch(submitMarkers());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -98,6 +103,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
|||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
dispatch(importFetchedStatuses(response.data));
|
dispatch(importFetchedStatuses(response.data));
|
||||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
|
||||||
|
|
||||||
|
if (timelineId === 'home') {
|
||||||
|
dispatch(submitMarkers());
|
||||||
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@ -114,7 +123,7 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun
|
|||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
max_id: maxId,
|
max_id: maxId,
|
||||||
any: parseTags(tags, 'any'),
|
any: parseTags(tags, 'any'),
|
||||||
all: parseTags(tags, 'all'),
|
all: parseTags(tags, 'all'),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { shallow } from 'enzyme';
|
import { render, fireEvent, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
import Button from '../button';
|
import Button from '../button';
|
||||||
@ -21,16 +21,16 @@ describe('<Button />', () => {
|
|||||||
|
|
||||||
it('handles click events using the given handler', () => {
|
it('handles click events using the given handler', () => {
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
const button = shallow(<Button onClick={handler} />);
|
render(<Button onClick={handler}>button</Button>);
|
||||||
button.find('button').simulate('click');
|
fireEvent.click(screen.getByText('button'));
|
||||||
|
|
||||||
expect(handler.mock.calls.length).toEqual(1);
|
expect(handler.mock.calls.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not handle click events if props.disabled given', () => {
|
it('does not handle click events if props.disabled given', () => {
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
const button = shallow(<Button onClick={handler} disabled />);
|
render(<Button onClick={handler} disabled>button</Button>);
|
||||||
button.find('button').simulate('click');
|
fireEvent.click(screen.getByText('button'));
|
||||||
|
|
||||||
expect(handler.mock.calls.length).toEqual(0);
|
expect(handler.mock.calls.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class AutosuggestHashtag extends React.PureComponent {
|
export default class AutosuggestHashtag extends React.PureComponent {
|
||||||
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
|||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render() {
|
||||||
const { tag } = this.props;
|
const { tag } = this.props;
|
||||||
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
const weeklyUses = tag.history && (
|
||||||
|
<ShortNumber
|
||||||
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-hashtag'>
|
<div className='autosuggest-hashtag'>
|
||||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
<div className='autosuggest-hashtag__name'>
|
||||||
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
#<strong>{tag.name}</strong>
|
||||||
|
</div>
|
||||||
|
{tag.history !== undefined && (
|
||||||
|
<div className='autosuggest-hashtag__uses'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='autosuggest_hashtag.per_week'
|
||||||
|
defaultMessage='{count} per week'
|
||||||
|
values={{ count: weeklyUses }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
inputRef={this.setTextarea}
|
ref={this.setTextarea}
|
||||||
className='autosuggest-textarea__textarea'
|
className='autosuggest-textarea__textarea'
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
65
app/javascript/mastodon/components/blurhash.js
Normal file
65
app/javascript/mastodon/components/blurhash.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import { decode } from 'blurhash';
|
||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef BlurhashPropsBase
|
||||||
|
* @property {string?} hash Hash to render
|
||||||
|
* @property {number} width
|
||||||
|
* Width of the blurred region in pixels. Defaults to 32
|
||||||
|
* @property {number} [height]
|
||||||
|
* Height of the blurred region in pixels. Defaults to width
|
||||||
|
* @property {boolean} [dummy]
|
||||||
|
* Whether dummy mode is enabled. If enabled, nothing is rendered
|
||||||
|
* and canvas left untouched
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that is used to render blurred of blurhash string
|
||||||
|
*
|
||||||
|
* @param {BlurhashProps} param1 Props of the component
|
||||||
|
* @returns Canvas which will render blurred region element to embed
|
||||||
|
*/
|
||||||
|
function Blurhash({
|
||||||
|
hash,
|
||||||
|
width = 32,
|
||||||
|
height = width,
|
||||||
|
dummy = false,
|
||||||
|
...canvasProps
|
||||||
|
}) {
|
||||||
|
const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: canvas } = canvasRef;
|
||||||
|
canvas.width = canvas.width; // resets canvas
|
||||||
|
|
||||||
|
if (dummy || !hash) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pixels = decode(hash, width, height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const imageData = new ImageData(pixels, width, height);
|
||||||
|
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blurhash decoding failure', { err, hash });
|
||||||
|
}
|
||||||
|
}, [dummy, hash, width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Blurhash.propTypes = {
|
||||||
|
hash: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
|
dummy: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(Blurhash);
|
62
app/javascript/mastodon/components/common_counter.js
Normal file
62
app/javascript/mastodon/components/common_counter.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// @ts-check
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns custom renderer for one of the common counter types
|
||||||
|
*
|
||||||
|
* @param {"statuses" | "following" | "followers"} counterType
|
||||||
|
* Type of the counter
|
||||||
|
* @param {boolean} isBold Whether display number must be displayed in bold
|
||||||
|
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||||
|
* Renderer function
|
||||||
|
* @throws If counterType is not covered by this function
|
||||||
|
*/
|
||||||
|
export function counterRenderer(counterType, isBold = true) {
|
||||||
|
/**
|
||||||
|
* @type {(displayNumber: JSX.Element) => JSX.Element}
|
||||||
|
*/
|
||||||
|
const renderCounter = isBold
|
||||||
|
? (displayNumber) => <strong>{displayNumber}</strong>
|
||||||
|
: (displayNumber) => displayNumber;
|
||||||
|
|
||||||
|
switch (counterType) {
|
||||||
|
case 'statuses': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.statuses_counter'
|
||||||
|
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'following': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.following_counter'
|
||||||
|
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'followers': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers_counter'
|
||||||
|
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,65 @@
|
|||||||
|
// @ts-check
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import { shortNumberFormat } from '../utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to render counter of how much people are talking about hashtag
|
||||||
|
*
|
||||||
|
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||||
|
*/
|
||||||
|
const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='trends.counter_by_accounts'
|
||||||
|
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: <strong>{displayNumber}</strong>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => (
|
const Hashtag = ({ hashtag }) => (
|
||||||
<div className='trends__item'>
|
<div className='trends__item'>
|
||||||
<div className='trends__item__name'>
|
<div className='trends__item__name'>
|
||||||
<Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}>
|
<Permalink
|
||||||
|
href={hashtag.get('url')}
|
||||||
|
to={`/timelines/tag/${hashtag.get('name')}`}
|
||||||
|
>
|
||||||
#<span>{hashtag.get('name')}</span>
|
#<span>{hashtag.get('name')}</span>
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
|
<ShortNumber
|
||||||
|
value={
|
||||||
|
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
||||||
|
hashtag.getIn(['history', 1, 'accounts']) * 1
|
||||||
|
}
|
||||||
|
renderer={accountsCountRenderer}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
|
<ShortNumber
|
||||||
|
value={
|
||||||
|
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
||||||
|
hashtag.getIn(['history', 1, 'uses']) * 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
<Sparklines
|
||||||
|
width={50}
|
||||||
|
height={28}
|
||||||
|
data={hashtag
|
||||||
|
.get('history')
|
||||||
|
.reverse()
|
||||||
|
.map((day) => day.get('uses'))
|
||||||
|
.toArray()}
|
||||||
|
>
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,10 +7,11 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|||||||
import { isIOS } from '../is_mobile';
|
import { isIOS } from '../is_mobile';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
|
import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state';
|
||||||
import { decode } from 'blurhash';
|
import { debounce } from 'lodash';
|
||||||
|
import Blurhash from 'mastodon/components/blurhash';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' },
|
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide {number, plural, one {image} other {images}}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class Item extends React.PureComponent {
|
class Item extends React.PureComponent {
|
||||||
@ -73,36 +74,6 @@ class Item extends React.PureComponent {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
if (this.props.attachment.get('blurhash')) {
|
|
||||||
this._decode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
|
||||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
|
||||||
this._decode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_decode () {
|
|
||||||
if (!useBlurhash) return;
|
|
||||||
|
|
||||||
const hash = this.props.attachment.get('blurhash');
|
|
||||||
const pixels = decode(hash, 32, 32);
|
|
||||||
|
|
||||||
if (pixels) {
|
|
||||||
const ctx = this.canvas.getContext('2d');
|
|
||||||
const imageData = new ImageData(pixels, 32, 32);
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanvasRef = c => {
|
|
||||||
this.canvas = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageLoad = () => {
|
handleImageLoad = () => {
|
||||||
this.setState({ loaded: true });
|
this.setState({ loaded: true });
|
||||||
}
|
}
|
||||||
@ -165,7 +136,11 @@ class Item extends React.PureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'>
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} target='_blank' rel='noopener noreferrer'>
|
||||||
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
|
<Blurhash
|
||||||
|
hash={attachment.get('blurhash')}
|
||||||
|
className='media-gallery__preview'
|
||||||
|
dummy={!useBlurhash}
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -231,7 +206,13 @@ class Item extends React.PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
|
<Blurhash
|
||||||
|
hash={attachment.get('blurhash')}
|
||||||
|
dummy={!useBlurhash}
|
||||||
|
className={classNames('media-gallery__preview', {
|
||||||
|
'media-gallery__preview--hidden': visible && this.state.loaded,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
{visible && thumbnail}
|
{visible && thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -266,6 +247,14 @@ class MediaGallery extends React.PureComponent {
|
|||||||
width: this.props.defaultWidth,
|
width: this.props.defaultWidth,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
window.removeEventListener('resize', this.handleResize);
|
||||||
|
}
|
||||||
|
|
||||||
componentWillReceiveProps (nextProps) {
|
componentWillReceiveProps (nextProps) {
|
||||||
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
|
if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) {
|
||||||
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
|
this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' });
|
||||||
@ -274,6 +263,14 @@ class MediaGallery extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleResize = debounce(() => {
|
||||||
|
if (this.node) {
|
||||||
|
this._setDimensions();
|
||||||
|
}
|
||||||
|
}, 250, {
|
||||||
|
trailing: true,
|
||||||
|
});
|
||||||
|
|
||||||
handleOpen = () => {
|
handleOpen = () => {
|
||||||
if (this.props.onToggleVisibility) {
|
if (this.props.onToggleVisibility) {
|
||||||
this.props.onToggleVisibility();
|
this.props.onToggleVisibility();
|
||||||
@ -286,16 +283,26 @@ class MediaGallery extends React.PureComponent {
|
|||||||
this.props.onOpenMedia(this.props.media, index);
|
this.props.onOpenMedia(this.props.media, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRef = (node) => {
|
handleRef = c => {
|
||||||
if (node) {
|
this.node = c;
|
||||||
|
|
||||||
|
if (this.node) {
|
||||||
|
this._setDimensions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setDimensions () {
|
||||||
|
const width = this.node.offsetWidth;
|
||||||
|
|
||||||
// offsetWidth triggers a layout, so only calculate when we need to
|
// offsetWidth triggers a layout, so only calculate when we need to
|
||||||
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
|
if (this.props.cacheWidth) {
|
||||||
|
this.props.cacheWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
width: node.offsetWidth,
|
width: width,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
isFullSizeEligible() {
|
isFullSizeEligible() {
|
||||||
const { media } = this.props;
|
const { media } = this.props;
|
||||||
@ -338,7 +345,7 @@ class MediaGallery extends React.PureComponent {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (visible) {
|
} else if (visible) {
|
||||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />;
|
||||||
} else {
|
} else {
|
||||||
spoilerButton = (
|
spoilerButton = (
|
||||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||||
|
@ -66,7 +66,7 @@ export default class ModalRoot extends React.PureComponent {
|
|||||||
// immediately selectable, we have to wait for observers to run, as
|
// immediately selectable, we have to wait for observers to run, as
|
||||||
// described in https://github.com/WICG/inert#performance-and-gotchas
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
this.activeElement.focus();
|
this.activeElement.focus({ preventScroll: true });
|
||||||
this.activeElement = null;
|
this.activeElement = null;
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -10,10 +10,18 @@ import { List as ImmutableList } from 'immutable';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||||
import LoadingIndicator from './loading_indicator';
|
import LoadingIndicator from './loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
const MOUSE_IDLE_DELAY = 300;
|
const MOUSE_IDLE_DELAY = 300;
|
||||||
|
|
||||||
export default class ScrollableList extends PureComponent {
|
const mapStateToProps = (state, { scrollKey }) => {
|
||||||
|
return {
|
||||||
|
preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps, null, null, { forwardRef: true })
|
||||||
|
class ScrollableList extends PureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
@ -32,10 +40,12 @@ export default class ScrollableList extends PureComponent {
|
|||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
numPending: PropTypes.number,
|
numPending: PropTypes.number,
|
||||||
prepend: PropTypes.node,
|
prepend: PropTypes.node,
|
||||||
|
append: PropTypes.node,
|
||||||
alwaysPrepend: PropTypes.bool,
|
alwaysPrepend: PropTypes.bool,
|
||||||
emptyMessage: PropTypes.node,
|
emptyMessage: PropTypes.node,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
bindToDocument: PropTypes.bool,
|
bindToDocument: PropTypes.bool,
|
||||||
|
preventScroll: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -128,7 +138,7 @@ export default class ScrollableList extends PureComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
handleMouseIdle = () => {
|
handleMouseIdle = () => {
|
||||||
if (this.scrollToTopOnMouseIdle) {
|
if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) {
|
||||||
this.setScrollTop(0);
|
this.setScrollTop(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +188,7 @@ export default class ScrollableList extends PureComponent {
|
|||||||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
||||||
const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
|
const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0);
|
||||||
|
|
||||||
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) {
|
if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) {
|
||||||
return this.getScrollHeight() - this.getScrollTop();
|
return this.getScrollHeight() - this.getScrollTop();
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
@ -280,7 +290,7 @@ export default class ScrollableList extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
|
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
const childrenCount = React.Children.count(children);
|
const childrenCount = React.Children.count(children);
|
||||||
|
|
||||||
@ -327,6 +337,8 @@ export default class ScrollableList extends PureComponent {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{loadMore}
|
{loadMore}
|
||||||
|
|
||||||
|
{!hasMore && append}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
117
app/javascript/mastodon/components/short_number.js
Normal file
117
app/javascript/mastodon/components/short_number.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
|
||||||
|
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @callback ShortNumberRenderer
|
||||||
|
* @param {JSX.Element} displayNumber Number to display
|
||||||
|
* @param {number} pluralReady Number used for pluralization
|
||||||
|
* @returns {JSX.Element} Final render of number
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ShortNumberProps
|
||||||
|
* @property {number} value Number to display in short variant
|
||||||
|
* @property {ShortNumberRenderer} [renderer]
|
||||||
|
* Custom renderer for numbers, provided as a prop. If another renderer
|
||||||
|
* passed as a child of this component, this prop won't be used.
|
||||||
|
* @property {ShortNumberRenderer} [children]
|
||||||
|
* Custom renderer for numbers, provided as a child. If another renderer
|
||||||
|
* passed as a prop of this component, this one will be used instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders short big number to a shorter version
|
||||||
|
*
|
||||||
|
* @param {ShortNumberProps} param0 Props for the component
|
||||||
|
* @returns {JSX.Element} Rendered number
|
||||||
|
*/
|
||||||
|
function ShortNumber({ value, renderer, children }) {
|
||||||
|
const shortNumber = toShortNumber(value);
|
||||||
|
const [, division] = shortNumber;
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
if (children != null && renderer != null) {
|
||||||
|
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
const customRenderer = children != null ? children : renderer;
|
||||||
|
|
||||||
|
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
return customRenderer != null
|
||||||
|
? customRenderer(displayNumber, pluralReady(value, division))
|
||||||
|
: displayNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortNumber.propTypes = {
|
||||||
|
value: PropTypes.number.isRequired,
|
||||||
|
renderer: PropTypes.func,
|
||||||
|
children: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ShortNumberCounterProps
|
||||||
|
* @property {import('../utils/number').ShortNumber} value Short number
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders short number into corresponding localizable react fragment
|
||||||
|
*
|
||||||
|
* @param {ShortNumberCounterProps} param0 Props for the component
|
||||||
|
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
|
||||||
|
*/
|
||||||
|
function ShortNumberCounter({ value }) {
|
||||||
|
const [rawNumber, unit, maxFractionDigits = 0] = value;
|
||||||
|
|
||||||
|
const count = (
|
||||||
|
<FormattedNumber
|
||||||
|
value={rawNumber}
|
||||||
|
maximumFractionDigits={maxFractionDigits}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
let values = { count, rawNumber };
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case DECIMAL_UNITS.THOUSAND: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.thousand'
|
||||||
|
defaultMessage='{count}K'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case DECIMAL_UNITS.MILLION: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.million'
|
||||||
|
defaultMessage='{count}M'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case DECIMAL_UNITS.BILLION: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.billion'
|
||||||
|
defaultMessage='{count}B'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not sure if we should go farther - @Sasha-Sorokin
|
||||||
|
default: return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortNumberCounter.propTypes = {
|
||||||
|
value: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ShortNumber);
|
@ -10,7 +10,7 @@ import StatusContent from './status_content';
|
|||||||
import StatusActionBar from './status_action_bar';
|
import StatusActionBar from './status_action_bar';
|
||||||
import AttachmentList from './attachment_list';
|
import AttachmentList from './attachment_list';
|
||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
@ -51,6 +51,13 @@ export const defaultMediaVisibility = (status) => {
|
|||||||
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
|
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||||
|
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||||
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||||
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default @injectIntl
|
||||||
class Status extends ImmutablePureComponent {
|
class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -87,6 +94,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
cacheMediaWidth: PropTypes.func,
|
cacheMediaWidth: PropTypes.func,
|
||||||
cachedMediaWidth: PropTypes.number,
|
cachedMediaWidth: PropTypes.number,
|
||||||
|
scrollKey: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Avoid checking props that are functions (and whose equality will always
|
// Avoid checking props that are functions (and whose equality will always
|
||||||
@ -257,7 +265,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
let media = null;
|
let media = null;
|
||||||
let statusAvatar, prepend, rebloggedByText;
|
let statusAvatar, prepend, rebloggedByText;
|
||||||
|
|
||||||
const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
|
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props;
|
||||||
|
|
||||||
let { status, account, ...other } = this.props;
|
let { status, account, ...other } = this.props;
|
||||||
|
|
||||||
@ -345,9 +353,14 @@ class Status extends ImmutablePureComponent {
|
|||||||
<Component
|
<Component
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
peaks={[0]}
|
width={this.props.cachedMediaWidth}
|
||||||
height={70}
|
height={110}
|
||||||
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Bundle>
|
</Bundle>
|
||||||
@ -401,6 +414,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
compact
|
compact
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
cacheWidth={this.props.cacheMediaWidth}
|
||||||
defaultWidth={this.props.cachedMediaWidth}
|
defaultWidth={this.props.cachedMediaWidth}
|
||||||
|
sensitive={status.get('sensitive')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -413,6 +427,15 @@ class Status extends ImmutablePureComponent {
|
|||||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
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) },
|
||||||
|
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||||
|
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-type-${status.get('activity_pub_type') || 'none'}` , `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
<div className={classNames('status__wrapper', `status__wrapper-type-${status.get('activity_pub_type') || 'none'}` , `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||||
@ -422,6 +445,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||||
<div className='status__info'>
|
<div className='status__info'>
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||||
|
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||||
|
|
||||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||||
<div className='status__avatar'>
|
<div className='status__avatar'>
|
||||||
@ -436,7 +460,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
|
|
||||||
{media}
|
{media}
|
||||||
|
|
||||||
<StatusActionBar status={status} account={account} {...other} />
|
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</HotKeys>
|
||||||
|
@ -86,6 +86,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
onPin: PropTypes.func,
|
onPin: PropTypes.func,
|
||||||
onBookmark: PropTypes.func,
|
onBookmark: PropTypes.func,
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
|
scrollKey: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,7 +231,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, relationship, intl, withDismiss } = this.props;
|
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
|
||||||
|
|
||||||
const mutingConversation = status.get('muted');
|
const mutingConversation = status.get('muted');
|
||||||
const anonymousAccess = !me;
|
const anonymousAccess = !me;
|
||||||
@ -239,9 +240,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
const federated = !status.get('local_only');
|
const federated = !status.get('local_only');
|
||||||
|
|
||||||
let menu = [];
|
let menu = [];
|
||||||
let reblogIcon = 'retweet';
|
|
||||||
let replyIcon;
|
|
||||||
let replyTitle;
|
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||||
|
|
||||||
@ -261,10 +259,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
if (status.getIn(['account', 'id']) === me) {
|
if (status.getIn(['account', 'id']) === me) {
|
||||||
if (publicStatus) {
|
if (publicStatus) {
|
||||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||||
} else {
|
|
||||||
if (status.get('visibility') === 'private') {
|
|
||||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||||
@ -307,12 +301,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('visibility') === 'direct') {
|
let replyIcon;
|
||||||
reblogIcon = 'envelope';
|
let replyTitle;
|
||||||
} else if (status.get('visibility') === 'private') {
|
|
||||||
reblogIcon = 'lock';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.get('in_reply_to_id', null) === null) {
|
if (status.get('in_reply_to_id', null) === null) {
|
||||||
replyIcon = 'reply';
|
replyIcon = 'reply';
|
||||||
replyTitle = intl.formatMessage(messages.reply);
|
replyTitle = intl.formatMessage(messages.reply);
|
||||||
@ -321,6 +311,19 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
replyTitle = intl.formatMessage(messages.replyAll);
|
replyTitle = intl.formatMessage(messages.replyAll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||||
|
|
||||||
|
let reblogTitle = '';
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||||
|
} else if (publicStatus) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog);
|
||||||
|
} else if (reblogPrivate) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||||
|
} else {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||||
|
}
|
||||||
|
|
||||||
const shareButton = ('share' in navigator) && publicStatus && federated && (
|
const shareButton = ('share' in navigator) && publicStatus && federated && (
|
||||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||||
);
|
);
|
||||||
@ -328,13 +331,22 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
|
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
|
||||||
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||||
{shareButton}
|
{shareButton}
|
||||||
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
<div className='status__action-bar-dropdown'>
|
||||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
|
<DropdownMenuContainer
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
disabled={anonymousAccess}
|
||||||
|
status={status}
|
||||||
|
items={menu}
|
||||||
|
icon='ellipsis-h'
|
||||||
|
size={18}
|
||||||
|
direction='right'
|
||||||
|
title={intl.formatMessage(messages.more)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{ !federated &&
|
{ !federated &&
|
||||||
<IconButton className='status__action-bar-button' disabled title={intl.formatMessage(messages.local_only)} icon='chain-broken' />
|
<IconButton className='status__action-bar-button' disabled title={intl.formatMessage(messages.local_only)} icon='chain-broken' />
|
||||||
|
@ -99,6 +99,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
|
scrollKey={this.props.scrollKey}
|
||||||
showThread
|
showThread
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
18
app/javascript/mastodon/components/timeline_hint.js
Normal file
18
app/javascript/mastodon/components/timeline_hint.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
const TimelineHint = ({ resource, url }) => (
|
||||||
|
<div className='timeline-hint'>
|
||||||
|
<strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
|
||||||
|
<br />
|
||||||
|
<a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
TimelineHint.propTypes = {
|
||||||
|
resource: PropTypes.node.isRequired,
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimelineHint;
|
@ -12,7 +12,7 @@ const mapStateToProps = state => ({
|
|||||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { status, items }) => ({
|
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
|
||||||
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
|
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
|
||||||
if (status) {
|
if (status) {
|
||||||
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
|
||||||
@ -22,7 +22,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
|
|||||||
status,
|
status,
|
||||||
actions: items,
|
actions: items,
|
||||||
onClick: onItemClick,
|
onClick: onItemClick,
|
||||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
}) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey));
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(id) {
|
onClose(id) {
|
||||||
|
@ -0,0 +1,170 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
import { is } from 'immutable';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
||||||
|
});
|
||||||
|
|
||||||
|
class InlineAlert extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
show: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
mountMessage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
static TRANSITION_DELAY = 200;
|
||||||
|
|
||||||
|
componentWillReceiveProps (nextProps) {
|
||||||
|
if (!this.props.show && nextProps.show) {
|
||||||
|
this.setState({ mountMessage: true });
|
||||||
|
} else if (this.props.show && !nextProps.show) {
|
||||||
|
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { show } = this.props;
|
||||||
|
const { mountMessage } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
||||||
|
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class AccountNote extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
value: PropTypes.string,
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
value: null,
|
||||||
|
saving: false,
|
||||||
|
saved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this._reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (nextProps) {
|
||||||
|
const accountWillChange = !is(this.props.account, nextProps.account);
|
||||||
|
const newState = {};
|
||||||
|
|
||||||
|
if (accountWillChange && this._isDirty()) {
|
||||||
|
this._save(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountWillChange || nextProps.value === this.state.value) {
|
||||||
|
newState.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.value !== nextProps.value) {
|
||||||
|
newState.value = nextProps.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (this._isDirty()) {
|
||||||
|
this._save(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextareaRef = c => {
|
||||||
|
this.textarea = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange = e => {
|
||||||
|
this.setState({ value: e.target.value, saving: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = e => {
|
||||||
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this._save();
|
||||||
|
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.blur();
|
||||||
|
}
|
||||||
|
} else if (e.keyCode === 27) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this._reset(() => {
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur = () => {
|
||||||
|
if (this._isDirty()) {
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_save (showMessage = true) {
|
||||||
|
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_reset (callback) {
|
||||||
|
this.setState({ value: this.props.value }, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDirty () {
|
||||||
|
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { account, intl } = this.props;
|
||||||
|
const { value, saved } = this.state;
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account__header__account-note'>
|
||||||
|
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||||
|
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id={`account-note-${account.get('id')}`}
|
||||||
|
className='account__header__account-note__content'
|
||||||
|
disabled={this.props.value === null || value === null}
|
||||||
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
onBlur={this.handleBlur}
|
||||||
|
ref={this.setTextareaRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -8,9 +8,11 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import Avatar from 'mastodon/components/avatar';
|
import Avatar from 'mastodon/components/avatar';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import { counterRenderer } from 'mastodon/components/common_counter';
|
||||||
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
|
import AccountNoteContainer from '../containers/account_note_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
@ -312,20 +314,31 @@ class Header extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{account.get('id') !== me && <AccountNoteContainer account={account} />}
|
||||||
|
|
||||||
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
|
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account__header__extra__links'>
|
<div className='account__header__extra__links'>
|
||||||
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Posts' />
|
<ShortNumber
|
||||||
|
value={account.get('statuses_count')}
|
||||||
|
renderer={counterRenderer('statuses')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' />
|
<ShortNumber
|
||||||
|
value={account.get('following_count')}
|
||||||
|
renderer={counterRenderer('following')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' />
|
<ShortNumber
|
||||||
|
value={account.get('followers_count')}
|
||||||
|
renderer={counterRenderer('followers')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { submitAccountNote } from 'mastodon/actions/account_notes';
|
||||||
|
import AccountNote from '../components/account_note';
|
||||||
|
|
||||||
|
const mapStateToProps = (state, { account }) => ({
|
||||||
|
value: account.getIn(['relationship', 'note']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||||
|
|
||||||
|
onSave (value) {
|
||||||
|
dispatch(submitAccountNote(account.get('id'), value));
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
@ -1,7 +1,7 @@
|
|||||||
import { decode } from 'blurhash';
|
import Blurhash from 'mastodon/components/blurhash';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
|
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
|
||||||
import { isIOS } from 'mastodon/is_mobile';
|
import { isIOS } from 'mastodon/is_mobile';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@ -21,34 +21,6 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||||||
loaded: false,
|
loaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
if (this.props.attachment.get('blurhash')) {
|
|
||||||
this._decode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
|
||||||
if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
|
|
||||||
this._decode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_decode () {
|
|
||||||
const hash = this.props.attachment.get('blurhash');
|
|
||||||
const pixels = decode(hash, 32, 32);
|
|
||||||
|
|
||||||
if (pixels) {
|
|
||||||
const ctx = this.canvas.getContext('2d');
|
|
||||||
const imageData = new ImageData(pixels, 32, 32);
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanvasRef = c => {
|
|
||||||
this.canvas = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageLoad = () => {
|
handleImageLoad = () => {
|
||||||
this.setState({ loaded: true });
|
this.setState({ loaded: true });
|
||||||
}
|
}
|
||||||
@ -91,55 +63,7 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||||||
const status = attachment.get('status');
|
const status = attachment.get('status');
|
||||||
const title = status.get('spoiler_text') || attachment.get('description');
|
const title = status.get('spoiler_text') || attachment.get('description');
|
||||||
|
|
||||||
let thumbnail = '';
|
let thumbnail, label, icon, content;
|
||||||
let icon;
|
|
||||||
|
|
||||||
if (attachment.get('type') === 'unknown') {
|
|
||||||
// Skip
|
|
||||||
} else if (attachment.get('type') === 'audio') {
|
|
||||||
thumbnail = (
|
|
||||||
<span className='account-gallery__item__icons'>
|
|
||||||
<Icon id='music' />
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
} else if (attachment.get('type') === 'image') {
|
|
||||||
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
|
||||||
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
|
||||||
const x = ((focusX / 2) + .5) * 100;
|
|
||||||
const y = ((focusY / -2) + .5) * 100;
|
|
||||||
|
|
||||||
thumbnail = (
|
|
||||||
<img
|
|
||||||
src={attachment.get('preview_url')}
|
|
||||||
alt={attachment.get('description')}
|
|
||||||
title={attachment.get('description')}
|
|
||||||
style={{ objectPosition: `${x}% ${y}%` }}
|
|
||||||
onLoad={this.handleImageLoad}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
|
|
||||||
const autoPlay = !isIOS() && autoPlayGif;
|
|
||||||
const label = attachment.get('type') === 'video' ? <Icon id='play' /> : 'GIF';
|
|
||||||
|
|
||||||
thumbnail = (
|
|
||||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
|
||||||
<video
|
|
||||||
className='media-gallery__item-gifv-thumbnail'
|
|
||||||
aria-label={attachment.get('description')}
|
|
||||||
title={attachment.get('description')}
|
|
||||||
role='application'
|
|
||||||
src={attachment.get('url')}
|
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
autoPlay={autoPlay}
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className='media-gallery__gifv__label'>{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
icon = (
|
icon = (
|
||||||
@ -147,14 +71,72 @@ export default class MediaItem extends ImmutablePureComponent {
|
|||||||
<Icon id='eye-slash' />
|
<Icon id='eye-slash' />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
if (['audio', 'video'].includes(attachment.get('type'))) {
|
||||||
|
content = (
|
||||||
|
<img
|
||||||
|
src={attachment.get('preview_url') || attachment.getIn(['account', 'avatar_static'])}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attachment.get('type') === 'audio') {
|
||||||
|
label = <Icon id='music' />;
|
||||||
|
} else {
|
||||||
|
label = <Icon id='play' />;
|
||||||
|
}
|
||||||
|
} else if (attachment.get('type') === 'image') {
|
||||||
|
const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
|
||||||
|
const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
|
||||||
|
const x = ((focusX / 2) + .5) * 100;
|
||||||
|
const y = ((focusY / -2) + .5) * 100;
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<img
|
||||||
|
src={attachment.get('preview_url')}
|
||||||
|
alt={attachment.get('description')}
|
||||||
|
style={{ objectPosition: `${x}% ${y}%` }}
|
||||||
|
onLoad={this.handleImageLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (attachment.get('type') === 'gifv') {
|
||||||
|
content = (
|
||||||
|
<video
|
||||||
|
className='media-gallery__item-gifv-thumbnail'
|
||||||
|
aria-label={attachment.get('description')}
|
||||||
|
role='application'
|
||||||
|
src={attachment.get('url')}
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseLeave={this.handleMouseLeave}
|
||||||
|
autoPlay={!isIOS() && autoPlayGif}
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
label = 'GIF';
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail = (
|
||||||
|
<div className='media-gallery__gifv'>
|
||||||
|
{content}
|
||||||
|
|
||||||
|
<span className='media-gallery__gifv__label'>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-gallery__item' style={{ width, height }}>
|
<div className='account-gallery__item' style={{ width, height }}>
|
||||||
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
<a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
|
||||||
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
|
<Blurhash
|
||||||
{visible && thumbnail}
|
hash={attachment.get('blurhash')}
|
||||||
{!visible && icon}
|
className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })}
|
||||||
|
dummy={!useBlurhash}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visible ? thumbnail : icon}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -101,9 +101,9 @@ class AccountGallery extends ImmutablePureComponent {
|
|||||||
|
|
||||||
handleOpenMedia = attachment => {
|
handleOpenMedia = attachment => {
|
||||||
if (attachment.get('type') === 'video') {
|
if (attachment.get('type') === 'video') {
|
||||||
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
|
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
|
||||||
} else if (attachment.get('type') === 'audio') {
|
} else if (attachment.get('type') === 'audio') {
|
||||||
this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status') }));
|
this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
|
||||||
} else {
|
} else {
|
||||||
const media = attachment.getIn(['status', 'media_attachments']);
|
const media = attachment.getIn(['status', 'media_attachments']);
|
||||||
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
|
||||||
|
@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
|
|||||||
onUnblockDomain: PropTypes.func.isRequired,
|
onUnblockDomain: PropTypes.func.isRequired,
|
||||||
onEndorseToggle: PropTypes.func.isRequired,
|
onEndorseToggle: PropTypes.func.isRequired,
|
||||||
onAddToList: PropTypes.func.isRequired,
|
onAddToList: PropTypes.func.isRequired,
|
||||||
|
onEditAccountNote: PropTypes.func.isRequired,
|
||||||
hideTabs: PropTypes.bool,
|
hideTabs: PropTypes.bool,
|
||||||
domain: PropTypes.string.isRequired,
|
domain: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
|
|||||||
this.props.onAddToList(this.props.account);
|
this.props.onAddToList(this.props.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEditAccountNote = () => {
|
||||||
|
this.props.onEditAccountNote(this.props.account);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, hideTabs, identity_proofs } = this.props;
|
const { account, hideTabs, identity_proofs } = this.props;
|
||||||
|
|
||||||
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
|||||||
onUnblockDomain={this.handleUnblockDomain}
|
onUnblockDomain={this.handleUnblockDomain}
|
||||||
onEndorseToggle={this.handleEndorseToggle}
|
onEndorseToggle={this.handleEndorseToggle}
|
||||||
onAddToList={this.handleAddToList}
|
onAddToList={this.handleAddToList}
|
||||||
|
onEditAccountNote={this.handleEditAccountNote}
|
||||||
domain={this.props.domain}
|
domain={this.props.domain}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -14,6 +14,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
|
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
|
||||||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||||
|
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
|
||||||
|
|
||||||
const emptyList = ImmutableList();
|
const emptyList = ImmutableList();
|
||||||
|
|
||||||
@ -21,6 +24,8 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
|
|||||||
const path = withReplies ? `${accountId}:with_replies` : accountId;
|
const path = withReplies ? `${accountId}:with_replies` : accountId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
||||||
|
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||||
isAccount: !!state.getIn(['accounts', accountId]),
|
isAccount: !!state.getIn(['accounts', accountId]),
|
||||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
||||||
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
|
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
|
||||||
@ -30,6 +35,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RemoteHint = ({ url }) => (
|
||||||
|
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older posts' />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteHint.propTypes = {
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
class AccountTimeline extends ImmutablePureComponent {
|
class AccountTimeline extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -44,32 +57,54 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||||||
withReplies: PropTypes.bool,
|
withReplies: PropTypes.bool,
|
||||||
blockedBy: PropTypes.bool,
|
blockedBy: PropTypes.bool,
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
|
remote: PropTypes.bool,
|
||||||
|
remoteUrl: PropTypes.string,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillMount () {
|
componentWillMount () {
|
||||||
const { params: { accountId }, withReplies } = this.props;
|
const { params: { accountId }, withReplies, dispatch } = this.props;
|
||||||
|
|
||||||
this.props.dispatch(fetchAccount(accountId));
|
dispatch(fetchAccount(accountId));
|
||||||
this.props.dispatch(fetchAccountIdentityProofs(accountId));
|
dispatch(fetchAccountIdentityProofs(accountId));
|
||||||
|
|
||||||
if (!withReplies) {
|
if (!withReplies) {
|
||||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
dispatch(expandAccountFeaturedTimeline(accountId));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
|
dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
dispatch(connectTimeline(`account:${me}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps (nextProps) {
|
componentWillReceiveProps (nextProps) {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
|
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
|
||||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
dispatch(fetchAccount(nextProps.params.accountId));
|
||||||
this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
|
dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
|
||||||
|
|
||||||
if (!nextProps.withReplies) {
|
if (!nextProps.withReplies) {
|
||||||
this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
|
dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
|
dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextProps.params.accountId === me && this.props.params.accountId !== me) {
|
||||||
|
dispatch(connectTimeline(`account:${me}`));
|
||||||
|
} else if (this.props.params.accountId === me && nextProps.params.accountId !== me) {
|
||||||
|
dispatch(disconnectTimeline(`account:${me}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
const { dispatch, params: { accountId } } = this.props;
|
||||||
|
|
||||||
|
if (accountId === me) {
|
||||||
|
dispatch(disconnectTimeline(`account:${me}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +113,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props;
|
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
@ -97,7 +132,17 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />;
|
let emptyMessage;
|
||||||
|
|
||||||
|
if (blockedBy) {
|
||||||
|
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
||||||
|
} else if (remote && statusIds.isEmpty()) {
|
||||||
|
emptyMessage = <RemoteHint url={remoteUrl} />;
|
||||||
|
} else {
|
||||||
|
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts here!' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
@ -106,6 +151,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||||||
<StatusList
|
<StatusList
|
||||||
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
|
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
|
append={remoteMessage}
|
||||||
scrollKey='account_timeline'
|
scrollKey='account_timeline'
|
||||||
statusIds={blockedBy ? emptyList : statusIds}
|
statusIds={blockedBy ? emptyList : statusIds}
|
||||||
featuredStatusIds={featuredStatusIds}
|
featuredStatusIds={featuredStatusIds}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import WaveSurfer from 'wavesurfer.js';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import { formatTime } from 'mastodon/features/video';
|
import { formatTime } from 'mastodon/features/video';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import Visualizer from './visualizer';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
@ -15,131 +17,155 @@ const messages = defineMessages({
|
|||||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TICK_SIZE = 10;
|
||||||
|
const PADDING = 180;
|
||||||
|
|
||||||
export default @injectIntl
|
export default @injectIntl
|
||||||
class Audio extends React.PureComponent {
|
class Audio extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
src: PropTypes.string.isRequired,
|
src: PropTypes.string.isRequired,
|
||||||
alt: PropTypes.string,
|
alt: PropTypes.string,
|
||||||
|
poster: PropTypes.string,
|
||||||
duration: PropTypes.number,
|
duration: PropTypes.number,
|
||||||
peaks: PropTypes.arrayOf(PropTypes.number),
|
width: PropTypes.number,
|
||||||
height: PropTypes.number,
|
height: PropTypes.number,
|
||||||
preload: PropTypes.bool,
|
|
||||||
editable: PropTypes.bool,
|
editable: PropTypes.bool,
|
||||||
|
fullscreen: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
cacheWidth: PropTypes.func,
|
||||||
|
backgroundColor: PropTypes.string,
|
||||||
|
foregroundColor: PropTypes.string,
|
||||||
|
accentColor: PropTypes.string,
|
||||||
|
autoPlay: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
width: this.props.width,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
buffer: 0,
|
||||||
duration: null,
|
duration: null,
|
||||||
paused: true,
|
paused: true,
|
||||||
muted: false,
|
muted: false,
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
|
dragging: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hard coded in components.scss
|
constructor (props) {
|
||||||
// Any way to get ::before values programatically?
|
super(props);
|
||||||
volWidth = 50;
|
this.visualizer = new Visualizer(TICK_SIZE);
|
||||||
volOffset = 70;
|
}
|
||||||
|
|
||||||
volHandleOffset = v => {
|
setPlayerRef = c => {
|
||||||
const offset = v * this.volWidth + this.volOffset;
|
this.player = c;
|
||||||
|
|
||||||
return (offset > 110) ? 110 : offset;
|
if (this.player) {
|
||||||
|
this._setDimensions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setDimensions () {
|
||||||
|
const width = this.player.offsetWidth;
|
||||||
|
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
||||||
|
|
||||||
|
if (this.props.cacheWidth) {
|
||||||
|
this.props.cacheWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSeekRef = c => {
|
||||||
|
this.seek = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolumeRef = c => {
|
setVolumeRef = c => {
|
||||||
this.volume = c;
|
this.volume = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWaveformRef = c => {
|
setAudioRef = c => {
|
||||||
this.waveform = c;
|
this.audio = c;
|
||||||
|
|
||||||
|
if (this.audio) {
|
||||||
|
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasRef = c => {
|
||||||
|
this.canvas = c;
|
||||||
|
|
||||||
|
this.visualizer.setCanvas(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
if (this.waveform) {
|
|
||||||
this._updateWaveform();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', this.handleScroll);
|
window.addEventListener('scroll', this.handleScroll);
|
||||||
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps, prevState) {
|
||||||
if (this.waveform && prevProps.src !== this.props.src) {
|
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
|
||||||
this._updateWaveform();
|
this._clear();
|
||||||
|
this._draw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
window.removeEventListener('scroll', this.handleScroll);
|
window.removeEventListener('scroll', this.handleScroll);
|
||||||
|
window.removeEventListener('resize', this.handleResize);
|
||||||
if (this.wavesurfer) {
|
|
||||||
this.wavesurfer.destroy();
|
|
||||||
this.wavesurfer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateWaveform () {
|
|
||||||
const { src, height, duration, peaks, preload } = this.props;
|
|
||||||
|
|
||||||
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
|
|
||||||
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
|
|
||||||
|
|
||||||
if (this.wavesurfer) {
|
|
||||||
this.wavesurfer.destroy();
|
|
||||||
this.loaded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wavesurfer = WaveSurfer.create({
|
|
||||||
container: this.waveform,
|
|
||||||
height,
|
|
||||||
barWidth: 3,
|
|
||||||
cursorWidth: 0,
|
|
||||||
progressColor,
|
|
||||||
waveColor,
|
|
||||||
backend: 'MediaElement',
|
|
||||||
interact: preload,
|
|
||||||
});
|
|
||||||
|
|
||||||
wavesurfer.setVolume(this.state.volume);
|
|
||||||
|
|
||||||
if (preload) {
|
|
||||||
wavesurfer.load(src);
|
|
||||||
this.loaded = true;
|
|
||||||
} else {
|
|
||||||
wavesurfer.load(src, peaks, 'none', duration);
|
|
||||||
this.loaded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
|
|
||||||
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
|
|
||||||
wavesurfer.on('pause', () => this.setState({ paused: true }));
|
|
||||||
wavesurfer.on('play', () => this.setState({ paused: false }));
|
|
||||||
wavesurfer.on('volume', volume => this.setState({ volume }));
|
|
||||||
wavesurfer.on('mute', muted => this.setState({ muted }));
|
|
||||||
|
|
||||||
this.wavesurfer = wavesurfer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePlay = () => {
|
togglePlay = () => {
|
||||||
if (this.state.paused) {
|
if (this.state.paused) {
|
||||||
if (!this.props.preload && !this.loaded) {
|
this.setState({ paused: false }, () => this.audio.play());
|
||||||
this.wavesurfer.createBackend();
|
} else {
|
||||||
this.wavesurfer.createPeakCache();
|
this.setState({ paused: true }, () => this.audio.pause());
|
||||||
this.wavesurfer.load(this.props.src);
|
}
|
||||||
this.wavesurfer.toggleInteraction();
|
|
||||||
this.loaded = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ paused: false }, () => this.wavesurfer.play());
|
handleResize = debounce(() => {
|
||||||
} else {
|
if (this.player) {
|
||||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
this._setDimensions();
|
||||||
|
}
|
||||||
|
}, 250, {
|
||||||
|
trailing: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePlay = () => {
|
||||||
|
this.setState({ paused: false });
|
||||||
|
|
||||||
|
if (this.canvas && !this.audioContext) {
|
||||||
|
this._initAudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._renderCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePause = () => {
|
||||||
|
this.setState({ paused: true });
|
||||||
|
|
||||||
|
if (this.audioContext) {
|
||||||
|
this.audioContext.suspend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleProgress = () => {
|
||||||
|
const lastTimeRange = this.audio.buffered.length - 1;
|
||||||
|
|
||||||
|
if (lastTimeRange > -1) {
|
||||||
|
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMute = () => {
|
toggleMute = () => {
|
||||||
const muted = !this.state.muted;
|
const muted = !this.state.muted;
|
||||||
this.setState({ muted }, () => this.wavesurfer.setMute(muted));
|
|
||||||
|
this.setState({ muted }, () => {
|
||||||
|
this.audio.muted = muted;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleVolumeMouseDown = e => {
|
handleVolumeMouseDown = e => {
|
||||||
@ -161,86 +187,239 @@ class Audio extends React.PureComponent {
|
|||||||
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMouseDown = e => {
|
||||||
|
document.addEventListener('mousemove', this.handleMouseMove, true);
|
||||||
|
document.addEventListener('mouseup', this.handleMouseUp, true);
|
||||||
|
document.addEventListener('touchmove', this.handleMouseMove, true);
|
||||||
|
document.addEventListener('touchend', this.handleMouseUp, true);
|
||||||
|
|
||||||
|
this.setState({ dragging: true });
|
||||||
|
this.audio.pause();
|
||||||
|
this.handleMouseMove(e);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
||||||
|
document.removeEventListener('touchmove', this.handleMouseMove, true);
|
||||||
|
document.removeEventListener('touchend', this.handleMouseUp, true);
|
||||||
|
|
||||||
|
this.setState({ dragging: false });
|
||||||
|
this.audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove = throttle(e => {
|
||||||
|
const { x } = getPointerPosition(this.seek, e);
|
||||||
|
const currentTime = this.audio.duration * x;
|
||||||
|
|
||||||
|
if (!isNaN(currentTime)) {
|
||||||
|
this.setState({ currentTime }, () => {
|
||||||
|
this.audio.currentTime = currentTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 15);
|
||||||
|
|
||||||
|
handleTimeUpdate = () => {
|
||||||
|
this.setState({
|
||||||
|
currentTime: this.audio.currentTime,
|
||||||
|
duration: Math.floor(this.audio.duration),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
handleMouseVolSlide = throttle(e => {
|
||||||
const rect = this.volume.getBoundingClientRect();
|
const { x } = getPointerPosition(this.volume, e);
|
||||||
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
|
|
||||||
|
|
||||||
if(!isNaN(x)) {
|
if(!isNaN(x)) {
|
||||||
let slideamt = x;
|
this.setState({ volume: x }, () => {
|
||||||
|
this.audio.volume = x;
|
||||||
if (x > 1) {
|
});
|
||||||
slideamt = 1;
|
|
||||||
} else if(x < 0) {
|
|
||||||
slideamt = 0;
|
|
||||||
}
|
}
|
||||||
|
}, 15);
|
||||||
this.wavesurfer.setVolume(slideamt);
|
|
||||||
}
|
|
||||||
}, 60);
|
|
||||||
|
|
||||||
handleScroll = throttle(() => {
|
handleScroll = throttle(() => {
|
||||||
if (!this.waveform || !this.wavesurfer) {
|
if (!this.canvas || !this.audio) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { top, height } = this.waveform.getBoundingClientRect();
|
const { top, height } = this.canvas.getBoundingClientRect();
|
||||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||||
|
|
||||||
if (!this.state.paused && !inView) {
|
if (!this.state.paused && !inView) {
|
||||||
this.setState({ paused: true }, () => this.wavesurfer.pause());
|
this.setState({ paused: true }, () => this.audio.pause());
|
||||||
|
}
|
||||||
|
}, 150, { trailing: true });
|
||||||
|
|
||||||
|
handleMouseEnter = () => {
|
||||||
|
this.setState({ hovered: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseLeave = () => {
|
||||||
|
this.setState({ hovered: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadedData = () => {
|
||||||
|
const { autoPlay } = this.props;
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
this.audio.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_initAudioContext () {
|
||||||
|
const context = new AudioContext();
|
||||||
|
const source = context.createMediaElementSource(this.audio);
|
||||||
|
|
||||||
|
this.visualizer.setAudioContext(context, source);
|
||||||
|
source.connect(context.destination);
|
||||||
|
|
||||||
|
this.audioContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDownload = () => {
|
||||||
|
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
||||||
|
const element = document.createElement('a');
|
||||||
|
const objectURL = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
element.setAttribute('href', objectURL);
|
||||||
|
element.setAttribute('download', fileNameFromURL(this.props.src));
|
||||||
|
|
||||||
|
document.body.appendChild(element);
|
||||||
|
element.click();
|
||||||
|
document.body.removeChild(element);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectURL);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderCanvas () {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this.audio) return;
|
||||||
|
|
||||||
|
this.handleTimeUpdate();
|
||||||
|
this._clear();
|
||||||
|
this._draw();
|
||||||
|
|
||||||
|
if (!this.state.paused) {
|
||||||
|
this._renderCanvas();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
this.visualizer.clear(this.state.width, this.state.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw() {
|
||||||
|
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRadius () {
|
||||||
|
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getScaleCoefficient () {
|
||||||
|
return (this.state.height || this.props.height) / 982;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCX() {
|
||||||
|
return Math.floor(this.state.width / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCY() {
|
||||||
|
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
|
||||||
|
}
|
||||||
|
|
||||||
|
_getAccentColor () {
|
||||||
|
return this.props.accentColor || '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
_getBackgroundColor () {
|
||||||
|
return this.props.backgroundColor || '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
_getForegroundColor () {
|
||||||
|
return this.props.foregroundColor || '#ffffff';
|
||||||
}
|
}
|
||||||
}, 150, { trailing: true })
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { height, intl, alt, editable } = this.props;
|
const { src, intl, alt, editable, autoPlay } = this.props;
|
||||||
const { paused, muted, volume, currentTime } = this.state;
|
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
|
||||||
|
const progress = (currentTime / duration) * 100;
|
||||||
const volumeWidth = muted ? 0 : volume * this.volWidth;
|
|
||||||
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('audio-player', { editable })}>
|
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||||
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
|
<audio
|
||||||
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
|
src={src}
|
||||||
|
ref={this.setAudioRef}
|
||||||
<div
|
preload={autoPlay ? 'auto' : 'none'}
|
||||||
className='audio-player__waveform'
|
onPlay={this.handlePlay}
|
||||||
aria-label={alt}
|
onPause={this.handlePause}
|
||||||
title={alt}
|
onProgress={this.handleProgress}
|
||||||
style={{ height }}
|
onLoadedData={this.handleLoadedData}
|
||||||
ref={this.setWaveformRef}
|
crossOrigin='anonymous'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
role='button'
|
||||||
|
className='audio-player__canvas'
|
||||||
|
width={this.state.width}
|
||||||
|
height={this.state.height}
|
||||||
|
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||||
|
ref={this.setCanvasRef}
|
||||||
|
onClick={this.togglePlay}
|
||||||
|
title={alt}
|
||||||
|
aria-label={alt}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={this.props.poster}
|
||||||
|
alt=''
|
||||||
|
width={(this._getRadius() - TICK_SIZE) * 2}
|
||||||
|
height={(this._getRadius() - TICK_SIZE) * 2}
|
||||||
|
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||||
|
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
||||||
|
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||||
|
tabIndex='0'
|
||||||
|
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='video-player__controls active'>
|
<div className='video-player__controls active'>
|
||||||
<div className='video-player__buttons-bar'>
|
<div className='video-player__buttons-bar'>
|
||||||
<div className='video-player__buttons left'>
|
<div className='video-player__buttons left'>
|
||||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
|
||||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
|
||||||
|
|
||||||
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
|
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
||||||
|
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
||||||
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
|
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={classNames('video-player__volume__handle')}
|
className={classNames('video-player__volume__handle')}
|
||||||
tabIndex='0'
|
tabIndex='0'
|
||||||
style={{ left: `${volumeHandleLoc}px` }}
|
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span>
|
<span className='video-player__time'>
|
||||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||||
<span className='video-player__time-sep'>/</span>
|
<span className='video-player__time-sep'>/</span>
|
||||||
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
<div className='video-player__buttons right'>
|
||||||
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
|
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
|
||||||
<a className='video-player__download__icon' href={this.props.src} download>
|
|
||||||
<Icon id={'download'} fixedWidth />
|
|
||||||
</a>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
136
app/javascript/mastodon/features/audio/visualizer.js
Normal file
136
app/javascript/mastodon/features/audio/visualizer.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const hex2rgba = (hex, alpha = 1) => {
|
||||||
|
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Visualizer {
|
||||||
|
|
||||||
|
constructor (tickSize) {
|
||||||
|
this.tickSize = tickSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvas(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
if (canvas) {
|
||||||
|
this.context = canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioContext(context, source) {
|
||||||
|
const analyser = context.createAnalyser();
|
||||||
|
|
||||||
|
analyser.smoothingTimeConstant = 0.6;
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
this.analyser = analyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTickPoints (count) {
|
||||||
|
const coords = [];
|
||||||
|
|
||||||
|
for(let i = 0; i < count; i++) {
|
||||||
|
const rad = Math.PI * 2 * i / count;
|
||||||
|
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
|
||||||
|
const dx1 = Math.ceil(cx + x1);
|
||||||
|
const dy1 = Math.ceil(cy + y1);
|
||||||
|
const dx2 = Math.ceil(cx + x2);
|
||||||
|
const dy2 = Math.ceil(cy + y2);
|
||||||
|
|
||||||
|
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
|
||||||
|
|
||||||
|
const lastColor = hex2rgba(mainColor, 0);
|
||||||
|
|
||||||
|
gradient.addColorStop(0, mainColor);
|
||||||
|
gradient.addColorStop(0.6, mainColor);
|
||||||
|
gradient.addColorStop(1, lastColor);
|
||||||
|
|
||||||
|
this.context.beginPath();
|
||||||
|
this.context.strokeStyle = gradient;
|
||||||
|
this.context.lineWidth = 2;
|
||||||
|
this.context.moveTo(dx1, dy1);
|
||||||
|
this.context.lineTo(dx2, dy2);
|
||||||
|
this.context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTicks (count, size, radius, scaleCoefficient) {
|
||||||
|
const ticks = this.getTickPoints(count);
|
||||||
|
const lesser = 200;
|
||||||
|
const m = [];
|
||||||
|
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
|
||||||
|
const frequencyData = new Uint8Array(bufferLength);
|
||||||
|
const allScales = [];
|
||||||
|
|
||||||
|
if (this.analyser) {
|
||||||
|
this.analyser.getByteFrequencyData(frequencyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
ticks.forEach((tick, i) => {
|
||||||
|
const coef = 1 - i / (ticks.length * 2.5);
|
||||||
|
|
||||||
|
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
|
||||||
|
|
||||||
|
if (delta < 0) {
|
||||||
|
delta = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = radius / (radius - (size + delta));
|
||||||
|
|
||||||
|
const x1 = tick.x * (radius - size);
|
||||||
|
const y1 = tick.y * (radius - size);
|
||||||
|
const x2 = x1 * k;
|
||||||
|
const y2 = y1 * k;
|
||||||
|
|
||||||
|
m.push({ x1, y1, x2, y2 });
|
||||||
|
|
||||||
|
if (i < 20) {
|
||||||
|
let scale = delta / (200 * scaleCoefficient);
|
||||||
|
scale = scale < 1 ? 1 : scale;
|
||||||
|
allScales.push(scale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
|
||||||
|
|
||||||
|
return m.map(({ x1, y1, x2, y2 }) => ({
|
||||||
|
x1: x1,
|
||||||
|
y1: y1,
|
||||||
|
x2: x2 * scale,
|
||||||
|
y2: y2 * scale,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear (width, height) {
|
||||||
|
this.context.clearRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw (cx, cy, color, radius, coefficient) {
|
||||||
|
this.context.save();
|
||||||
|
|
||||||
|
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
|
||||||
|
|
||||||
|
ticks.forEach(tick => {
|
||||||
|
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -199,12 +199,13 @@ class EmojiPickerMenu extends React.PureComponent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = emoji => {
|
handleClick = (emoji, event) => {
|
||||||
if (!emoji.native) {
|
if (!emoji.native) {
|
||||||
emoji.native = emoji.colons;
|
emoji.native = emoji.colons;
|
||||||
}
|
}
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) {
|
||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
|
}
|
||||||
this.props.onPick(emoji);
|
this.props.onPick(emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,11 +7,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
|
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
||||||
@ -60,11 +58,13 @@ class UploadButton extends ImmutablePureComponent {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message = intl.formatMessage(messages.upload);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-form__upload-button'>
|
<div className='compose-form__upload-button'>
|
||||||
<IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
<IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
||||||
<label>
|
<label>
|
||||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
|
<span style={{ display: 'none' }}>{message}</span>
|
||||||
<input
|
<input
|
||||||
key={resetFileKey}
|
key={resetFileKey}
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
|
@ -36,6 +36,7 @@ class Conversation extends ImmutablePureComponent {
|
|||||||
accounts: ImmutablePropTypes.list.isRequired,
|
accounts: ImmutablePropTypes.list.isRequired,
|
||||||
lastStatus: ImmutablePropTypes.map,
|
lastStatus: ImmutablePropTypes.map,
|
||||||
unread:PropTypes.bool.isRequired,
|
unread:PropTypes.bool.isRequired,
|
||||||
|
scrollKey: PropTypes.string,
|
||||||
onMoveUp: PropTypes.func,
|
onMoveUp: PropTypes.func,
|
||||||
onMoveDown: PropTypes.func,
|
onMoveDown: PropTypes.func,
|
||||||
markRead: PropTypes.func.isRequired,
|
markRead: PropTypes.func.isRequired,
|
||||||
@ -127,7 +128,7 @@ class Conversation extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { accounts, lastStatus, unread, intl } = this.props;
|
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
|
||||||
|
|
||||||
if (lastStatus === null) {
|
if (lastStatus === null) {
|
||||||
return null;
|
return null;
|
||||||
@ -194,7 +195,15 @@ class Conversation extends ImmutablePureComponent {
|
|||||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
<div className='status__action-bar-dropdown'>
|
||||||
<DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
|
<DropdownMenuContainer
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
status={lastStatus}
|
||||||
|
items={menu}
|
||||||
|
icon='ellipsis-h'
|
||||||
|
size={18}
|
||||||
|
direction='right'
|
||||||
|
title={intl.formatMessage(messages.more)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,6 +10,7 @@ export default class ConversationsList extends ImmutablePureComponent {
|
|||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
conversations: ImmutablePropTypes.list.isRequired,
|
conversations: ImmutablePropTypes.list.isRequired,
|
||||||
|
scrollKey: PropTypes.string.isRequired,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
onLoadMore: PropTypes.func,
|
onLoadMore: PropTypes.func,
|
||||||
@ -58,13 +59,14 @@ export default class ConversationsList extends ImmutablePureComponent {
|
|||||||
const { conversations, onLoadMore, ...other } = this.props;
|
const { conversations, onLoadMore, ...other } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
|
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
||||||
{conversations.map(item => (
|
{conversations.map(item => (
|
||||||
<ConversationContainer
|
<ConversationContainer
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
conversationId={item.get('id')}
|
conversationId={item.get('id')}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
|
scrollKey={this.props.scrollKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
|||||||
import IconButton from 'mastodon/components/icon_button';
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
import {
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
blockAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||||
|
|
||||||
@ -22,7 +28,10 @@ const messages = defineMessages({
|
|||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
unfollowConfirm: {
|
||||||
|
id: 'confirmations.unfollow.confirm',
|
||||||
|
defaultMessage: 'Unfollow',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
onFollow(account) {
|
||||||
onFollow (account) {
|
if (
|
||||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
account.getIn(['relationship', 'following']) ||
|
||||||
|
account.getIn(['relationship', 'requested'])
|
||||||
|
) {
|
||||||
if (unfollowModal) {
|
if (unfollowModal) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(
|
||||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
openModal('CONFIRM', {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(unfollowAccount(account.get('id')));
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
}
|
}
|
||||||
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onBlock (account) {
|
onBlock(account) {
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
if (account.getIn(['relationship', 'blocking'])) {
|
||||||
dispatch(unblockAccount(account.get('id')));
|
dispatch(unblockAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onMute (account) {
|
onMute(account) {
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(initMuteModal(account));
|
dispatch(initMuteModal(account));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default
|
||||||
|
@injectIntl
|
||||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||||
class AccountCard extends ImmutablePureComponent {
|
class AccountCard extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
|
|||||||
onMute: PropTypes.func.isRequired,
|
onMute: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
_updateEmojis () {
|
_updateEmojis() {
|
||||||
const node = this.node;
|
const node = this.node;
|
||||||
|
|
||||||
if (!node || autoPlayGif) {
|
if (!node || autoPlayGif) {
|
||||||
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount() {
|
||||||
this._updateEmojis();
|
this._updateEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate () {
|
componentDidUpdate() {
|
||||||
this._updateEmojis();
|
this._updateEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmojiMouseEnter = ({ target }) => {
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
target.src = target.getAttribute('data-original');
|
target.src = target.getAttribute('data-original');
|
||||||
}
|
};
|
||||||
|
|
||||||
handleEmojiMouseLeave = ({ target }) => {
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
target.src = target.getAttribute('data-static');
|
target.src = target.getAttribute('data-static');
|
||||||
}
|
};
|
||||||
|
|
||||||
handleFollow = () => {
|
handleFollow = () => {
|
||||||
this.props.onFollow(this.props.account);
|
this.props.onFollow(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleBlock = () => {
|
handleBlock = () => {
|
||||||
this.props.onBlock(this.props.account);
|
this.props.onBlock(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleMute = () => {
|
handleMute = () => {
|
||||||
this.props.onMute(this.props.account);
|
this.props.onMute(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
setRef = (c) => {
|
setRef = (c) => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
};
|
||||||
|
|
||||||
render () {
|
render() {
|
||||||
const { account, intl } = this.props;
|
const { account, intl } = this.props;
|
||||||
|
|
||||||
let buttons;
|
let buttons;
|
||||||
|
|
||||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
if (
|
||||||
|
account.get('id') !== me &&
|
||||||
|
account.get('relationship', null) !== null
|
||||||
|
) {
|
||||||
const following = account.getIn(['relationship', 'following']);
|
const following = account.getIn(['relationship', 'following']);
|
||||||
const requested = account.getIn(['relationship', 'requested']);
|
const requested = account.getIn(['relationship', 'requested']);
|
||||||
const blocking = account.getIn(['relationship', 'blocking']);
|
const blocking = account.getIn(['relationship', 'blocking']);
|
||||||
const muting = account.getIn(['relationship', 'muting']);
|
const muting = account.getIn(['relationship', 'muting']);
|
||||||
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
disabled
|
||||||
|
icon='hourglass'
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (blocking) {
|
} else if (blocking) {
|
||||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
active
|
||||||
|
icon='unlock'
|
||||||
|
title={intl.formatMessage(messages.unblock, {
|
||||||
|
name: account.get('username'),
|
||||||
|
})}
|
||||||
|
onClick={this.handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (muting) {
|
} else if (muting) {
|
||||||
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
active
|
||||||
|
icon='volume-up'
|
||||||
|
title={intl.formatMessage(messages.unmute, {
|
||||||
|
name: account.get('username'),
|
||||||
|
})}
|
||||||
|
onClick={this.handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (!account.get('moved') || following) {
|
} else if (!account.get('moved') || following) {
|
||||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
icon={following ? 'user-times' : 'user-plus'}
|
||||||
|
title={intl.formatMessage(
|
||||||
|
following ? messages.unfollow : messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={this.handleFollow}
|
||||||
|
active={following}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='directory__card'>
|
<div className='directory__card'>
|
||||||
<div className='directory__card__img'>
|
<div className='directory__card__img'>
|
||||||
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
|
<img
|
||||||
|
src={
|
||||||
|
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||||
|
}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__bar'>
|
<div className='directory__card__bar'>
|
||||||
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
<Permalink
|
||||||
|
className='directory__card__bar__name'
|
||||||
|
href={account.get('url')}
|
||||||
|
to={`/accounts/${account.get('id')}`}
|
||||||
|
>
|
||||||
<Avatar account={account} size={48} />
|
<Avatar account={account} size={48} />
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</Permalink>
|
</Permalink>
|
||||||
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__extra' ref={this.setRef}>
|
<div className='directory__card__extra' ref={this.setRef}>
|
||||||
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
|
<div
|
||||||
|
className='account__header__content'
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__extra'>
|
<div className='directory__card__extra'>
|
||||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
|
<div className='accounts-table__count'>
|
||||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
|
<ShortNumber value={account.get('statuses_count')} />
|
||||||
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
|
<small>
|
||||||
|
<FormattedMessage id='account.posts' defaultMessage='Toots' />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className='accounts-table__count'>
|
||||||
|
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers'
|
||||||
|
defaultMessage='Followers'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className='accounts-table__count'>
|
||||||
|
{account.get('last_status_at') === null ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.never_active'
|
||||||
|
defaultMessage='Never'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RelativeTimestamp timestamp={account.get('last_status_at')} />
|
||||||
|
)}{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.last_status'
|
||||||
|
defaultMessage='Last active'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -76,7 +76,17 @@ describe('emoji', () => {
|
|||||||
|
|
||||||
it('skips the textual presentation VS15 character', () => {
|
it('skips the textual presentation VS15 character', () => {
|
||||||
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
|
||||||
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />');
|
.toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does an simple emoji properly', () => {
|
||||||
|
expect(emojify('♀♂'))
|
||||||
|
.toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does an emoji containing ZWJ properly', () => {
|
||||||
|
expect(emojify('💂♀️💂♂️'))
|
||||||
|
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping));
|
|||||||
|
|
||||||
const assetHost = process.env.CDN_HOST || '';
|
const assetHost = process.env.CDN_HOST || '';
|
||||||
|
|
||||||
|
// Convert to file names from emojis. (For different variation selector emojis)
|
||||||
|
const emojiFilenames = (emojis) => {
|
||||||
|
return emojis.map(v => unicodeMapping[v].filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emoji requiring extra borders depending on theme
|
||||||
|
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']);
|
||||||
|
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
|
||||||
|
|
||||||
|
const emojiFilename = (filename) => {
|
||||||
|
const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji;
|
||||||
|
return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
|
||||||
|
};
|
||||||
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
const emojify = (str, customEmojis = {}) => {
|
||||||
const tagCharsWithoutEmojis = '<&';
|
const tagCharsWithoutEmojis = '<&';
|
||||||
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
|
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
|
||||||
@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => {
|
|||||||
} else { // matched to unicode emoji
|
} else { // matched to unicode emoji
|
||||||
const { filename, shortCode } = unicodeMapping[match];
|
const { filename, shortCode } = unicodeMapping[match];
|
||||||
const title = shortCode ? `:${shortCode}:` : '';
|
const title = shortCode ? `:${shortCode}:` : '';
|
||||||
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
|
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
|
||||||
rend = i + match.length;
|
rend = i + match.length;
|
||||||
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
|
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
|
||||||
if (str.codePointAt(rend) === 65038) {
|
if (str.codePointAt(rend) === 65038) {
|
||||||
|
@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
|
|||||||
import ColumnBackButton from '../../components/column_back_button';
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
import ScrollableList from '../../components/scrollable_list';
|
||||||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||||
|
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
|
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
|
||||||
|
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
|
||||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||||
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
|
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
|
||||||
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
|
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
|
||||||
@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
|
|||||||
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RemoteHint = ({ url }) => (
|
||||||
|
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteHint.propTypes = {
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
class Followers extends ImmutablePureComponent {
|
class Followers extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -38,6 +49,8 @@ class Followers extends ImmutablePureComponent {
|
|||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
blockedBy: PropTypes.bool,
|
blockedBy: PropTypes.bool,
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
|
remote: PropTypes.bool,
|
||||||
|
remoteUrl: PropTypes.string,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,7 +73,7 @@ class Followers extends ImmutablePureComponent {
|
|||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
|
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
@ -78,7 +91,17 @@ class Followers extends ImmutablePureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
|
let emptyMessage;
|
||||||
|
|
||||||
|
if (blockedBy) {
|
||||||
|
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
||||||
|
} else if (remote && accountIds.isEmpty()) {
|
||||||
|
emptyMessage = <RemoteHint url={remoteUrl} />;
|
||||||
|
} else {
|
||||||
|
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
@ -92,6 +115,7 @@ class Followers extends ImmutablePureComponent {
|
|||||||
shouldUpdateScroll={shouldUpdateScroll}
|
shouldUpdateScroll={shouldUpdateScroll}
|
||||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
|
append={remoteMessage}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
>
|
>
|
||||||
|
@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
|
|||||||
import ColumnBackButton from '../../components/column_back_button';
|
import ColumnBackButton from '../../components/column_back_button';
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
import ScrollableList from '../../components/scrollable_list';
|
||||||
import MissingIndicator from 'mastodon/components/missing_indicator';
|
import MissingIndicator from 'mastodon/components/missing_indicator';
|
||||||
|
import TimelineHint from 'mastodon/components/timeline_hint';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
|
remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
|
||||||
|
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
|
||||||
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
isAccount: !!state.getIn(['accounts', props.params.accountId]),
|
||||||
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
|
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
|
||||||
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
|
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
|
||||||
@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
|
|||||||
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RemoteHint = ({ url }) => (
|
||||||
|
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteHint.propTypes = {
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
class Following extends ImmutablePureComponent {
|
class Following extends ImmutablePureComponent {
|
||||||
|
|
||||||
@ -38,6 +49,8 @@ class Following extends ImmutablePureComponent {
|
|||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
blockedBy: PropTypes.bool,
|
blockedBy: PropTypes.bool,
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
|
remote: PropTypes.bool,
|
||||||
|
remoteUrl: PropTypes.string,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,7 +73,7 @@ class Following extends ImmutablePureComponent {
|
|||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
|
const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
|
||||||
|
|
||||||
if (!isAccount) {
|
if (!isAccount) {
|
||||||
return (
|
return (
|
||||||
@ -78,7 +91,17 @@ class Following extends ImmutablePureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
|
let emptyMessage;
|
||||||
|
|
||||||
|
if (blockedBy) {
|
||||||
|
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
||||||
|
} else if (remote && accountIds.isEmpty()) {
|
||||||
|
emptyMessage = <RemoteHint url={remoteUrl} />;
|
||||||
|
} else {
|
||||||
|
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
@ -92,6 +115,7 @@ class Following extends ImmutablePureComponent {
|
|||||||
shouldUpdateScroll={shouldUpdateScroll}
|
shouldUpdateScroll={shouldUpdateScroll}
|
||||||
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
|
append={remoteMessage}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
>
|
>
|
||||||
|
@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import Toggle from 'react-toggle';
|
import Toggle from 'react-toggle';
|
||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import { NonceProvider } from 'react-select';
|
||||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -58,6 +59,7 @@ class ColumnSettings extends React.PureComponent {
|
|||||||
{this.modeLabel(mode)}
|
{this.modeLabel(mode)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
isMulti
|
isMulti
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -70,6 +72,7 @@ class ColumnSettings extends React.PureComponent {
|
|||||||
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
||||||
noOptionsMessage={this.noOptionsMessage}
|
noOptionsMessage={this.noOptionsMessage}
|
||||||
/>
|
/>
|
||||||
|
</NonceProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import { connectHashtagStream } from '../../actions/streaming';
|
|||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
|
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
@ -76,13 +76,13 @@ class HashtagTimeline extends React.PureComponent {
|
|||||||
this.column.scrollTop();
|
this.column.scrollTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
_subscribe (dispatch, id, tags = {}) {
|
_subscribe (dispatch, id, tags = {}, local) {
|
||||||
let any = (tags.any || []).map(tag => tag.value);
|
let any = (tags.any || []).map(tag => tag.value);
|
||||||
let all = (tags.all || []).map(tag => tag.value);
|
let all = (tags.all || []).map(tag => tag.value);
|
||||||
let none = (tags.none || []).map(tag => tag.value);
|
let none = (tags.none || []).map(tag => tag.value);
|
||||||
|
|
||||||
[id, ...any].map(tag => {
|
[id, ...any].map(tag => {
|
||||||
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
|
this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
|
||||||
let tags = status.tags.map(tag => tag.name);
|
let tags = status.tags.map(tag => tag.name);
|
||||||
|
|
||||||
return all.filter(tag => tags.includes(tag)).length === all.length &&
|
return all.filter(tag => tags.includes(tag)).length === all.length &&
|
||||||
@ -100,7 +100,7 @@ class HashtagTimeline extends React.PureComponent {
|
|||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
const { id, tags, local } = this.props.params;
|
const { id, tags, local } = this.props.params;
|
||||||
|
|
||||||
this._subscribe(dispatch, id, tags);
|
this._subscribe(dispatch, id, tags, local);
|
||||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,8 +110,8 @@ class HashtagTimeline extends React.PureComponent {
|
|||||||
|
|
||||||
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
|
||||||
this._unsubscribe();
|
this._unsubscribe();
|
||||||
this._subscribe(dispatch, id, tags);
|
this._subscribe(dispatch, id, tags, local);
|
||||||
dispatch(clearTimeline(`hashtag:${id}`));
|
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
|
||||||
dispatch(expandHashtagTimeline(id, { tags, local }));
|
dispatch(expandHashtagTimeline(id, { tags, local }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -131,7 +131,7 @@ class HashtagTimeline extends React.PureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
const { shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
||||||
const { id } = this.props.params;
|
const { id, local } = this.props.params;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -153,7 +153,7 @@ class HashtagTimeline extends React.PureComponent {
|
|||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`hashtag_timeline-${columnId}`}
|
scrollKey={`hashtag_timeline-${columnId}`}
|
||||||
timelineId={`hashtag:${id}`}
|
timelineId={`hashtag:${id}${local ? ':local' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
|
||||||
shouldUpdateScroll={shouldUpdateScroll}
|
shouldUpdateScroll={shouldUpdateScroll}
|
||||||
|
@ -88,6 +88,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||||||
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
|
<td><kbd>alt</kbd>+<kbd>n</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new post' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><kbd>alt</kbd>+<kbd>x</kbd></td>
|
||||||
|
<td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>backspace</kbd></td>
|
<td><kbd>backspace</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
|
||||||
|
@ -203,10 +203,6 @@ class ActionBar extends React.PureComponent {
|
|||||||
if (me === status.getIn(['account', 'id'])) {
|
if (me === status.getIn(['account', 'id'])) {
|
||||||
if (publicStatus) {
|
if (publicStatus) {
|
||||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||||
} else {
|
|
||||||
if (status.get('visibility') === 'private') {
|
|
||||||
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
@ -263,14 +259,23 @@ class ActionBar extends React.PureComponent {
|
|||||||
replyIcon = 'reply-all';
|
replyIcon = 'reply-all';
|
||||||
}
|
}
|
||||||
|
|
||||||
let reblogIcon = 'retweet';
|
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||||
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
|
|
||||||
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
|
let reblogTitle;
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||||
|
} else if (publicStatus) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog);
|
||||||
|
} else if (reblogPrivate) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||||
|
} else {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='detailed-status__action-bar'>
|
<div className='detailed-status__action-bar'>
|
||||||
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
|
||||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus} active={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
|
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
|
||||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
|
||||||
{shareButton}
|
{shareButton}
|
||||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
|
||||||
|
@ -2,9 +2,13 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
import punycode from 'punycode';
|
import punycode from 'punycode';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { useBlurhash } from 'mastodon/initial_state';
|
||||||
|
import Blurhash from 'mastodon/components/blurhash';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
const IDNA_PREFIX = 'xn--';
|
const IDNA_PREFIX = 'xn--';
|
||||||
|
|
||||||
@ -63,6 +67,7 @@ export default class Card extends React.PureComponent {
|
|||||||
compact: PropTypes.bool,
|
compact: PropTypes.bool,
|
||||||
defaultWidth: PropTypes.number,
|
defaultWidth: PropTypes.number,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
|
sensitive: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -72,15 +77,46 @@ export default class Card extends React.PureComponent {
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
width: this.props.defaultWidth || 280,
|
width: this.props.defaultWidth || 280,
|
||||||
|
previewLoaded: false,
|
||||||
embedded: false,
|
embedded: false,
|
||||||
|
revealed: !this.props.sensitive,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillReceiveProps (nextProps) {
|
componentWillReceiveProps (nextProps) {
|
||||||
if (!Immutable.is(this.props.card, nextProps.card)) {
|
if (!Immutable.is(this.props.card, nextProps.card)) {
|
||||||
this.setState({ embedded: false });
|
this.setState({ embedded: false, previewLoaded: false });
|
||||||
|
}
|
||||||
|
if (this.props.sensitive !== nextProps.sensitive) {
|
||||||
|
this.setState({ revealed: !nextProps.sensitive });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
window.removeEventListener('resize', this.handleResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setDimensions () {
|
||||||
|
const width = this.node.offsetWidth;
|
||||||
|
|
||||||
|
if (this.props.cacheWidth) {
|
||||||
|
this.props.cacheWidth(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ width });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize = debounce(() => {
|
||||||
|
if (this.node) {
|
||||||
|
this._setDimensions();
|
||||||
|
}
|
||||||
|
}, 250, {
|
||||||
|
trailing: true,
|
||||||
|
});
|
||||||
|
|
||||||
handlePhotoClick = () => {
|
handlePhotoClick = () => {
|
||||||
const { card, onOpenMedia } = this.props;
|
const { card, onOpenMedia } = this.props;
|
||||||
|
|
||||||
@ -113,12 +149,23 @@ export default class Card extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
if (c) {
|
this.node = c;
|
||||||
if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
|
|
||||||
this.setState({ width: c.offsetWidth });
|
if (this.node) {
|
||||||
|
this._setDimensions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleImageLoad = () => {
|
||||||
|
this.setState({ previewLoaded: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReveal = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({ revealed: true });
|
||||||
|
}
|
||||||
|
|
||||||
renderVideo () {
|
renderVideo () {
|
||||||
const { card } = this.props;
|
const { card } = this.props;
|
||||||
const content = { __html: addAutoPlay(card.get('html')) };
|
const content = { __html: addAutoPlay(card.get('html')) };
|
||||||
@ -138,7 +185,7 @@ export default class Card extends React.PureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { card, maxDescription, compact } = this.props;
|
const { card, maxDescription, compact } = this.props;
|
||||||
const { width, embedded } = this.state;
|
const { width, embedded, revealed } = this.state;
|
||||||
|
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
return null;
|
return null;
|
||||||
@ -161,7 +208,26 @@ export default class Card extends React.PureComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let embed = '';
|
let embed = '';
|
||||||
let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
|
let canvas = (
|
||||||
|
<Blurhash
|
||||||
|
className={classnames('status-card__image-preview', {
|
||||||
|
'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
|
||||||
|
})}
|
||||||
|
hash={card.get('blurhash')}
|
||||||
|
dummy={!useBlurhash}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
|
||||||
|
let spoilerButton = (
|
||||||
|
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
|
||||||
|
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
spoilerButton = (
|
||||||
|
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||||
|
{spoilerButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
@ -175,20 +241,24 @@ export default class Card extends React.PureComponent {
|
|||||||
|
|
||||||
embed = (
|
embed = (
|
||||||
<div className='status-card__image'>
|
<div className='status-card__image'>
|
||||||
|
{canvas}
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
<div className='status-card__actions'>
|
<div className='status-card__actions'>
|
||||||
<div>
|
<div>
|
||||||
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
|
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
|
||||||
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
|
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{!revealed && spoilerButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} ref={this.setRef}>
|
<div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
|
||||||
{embed}
|
{embed}
|
||||||
{!compact && description}
|
{!compact && description}
|
||||||
</div>
|
</div>
|
||||||
@ -196,6 +266,7 @@ export default class Card extends React.PureComponent {
|
|||||||
} else if (card.get('image')) {
|
} else if (card.get('image')) {
|
||||||
embed = (
|
embed = (
|
||||||
<div className='status-card__image'>
|
<div className='status-card__image'>
|
||||||
|
{canvas}
|
||||||
{thumbnail}
|
{thumbnail}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
|
|||||||
import StatusContent from '../../../components/status_content';
|
import StatusContent from '../../../components/status_content';
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
import MediaGallery from '../../../components/media_gallery';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { defineMessages, injectIntl, FormattedDate } from 'react-intl';
|
import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Video from '../../video';
|
import Video from '../../video';
|
||||||
@ -18,10 +18,14 @@ import AnimatedNumber from 'mastodon/components/animated_number';
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
|
local_only: { id: 'status.local_only', defaultMessage: 'This post is only visible by other users of your instance' },
|
||||||
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
|
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||||
|
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||||
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@injectIntl
|
export default @injectIntl
|
||||||
export default class DetailedStatus extends ImmutablePureComponent {
|
class DetailedStatus extends ImmutablePureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
@ -96,9 +100,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
|
||||||
const intl = this.props.intl;
|
|
||||||
const outerStyle = { boxSizing: 'border-box' };
|
const outerStyle = { boxSizing: 'border-box' };
|
||||||
const { compact } = this.props;
|
const { intl, compact } = this.props;
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
@ -124,8 +127,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
height={110}
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
preload
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
|
height={150}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||||
@ -160,38 +166,48 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (status.get('spoiler_text').length === 0) {
|
} else if (status.get('spoiler_text').length === 0) {
|
||||||
media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
|
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('application')) {
|
if (status.get('application')) {
|
||||||
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
|
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('visibility') === 'direct') {
|
const visibilityIconInfo = {
|
||||||
reblogIcon = 'envelope';
|
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||||
} else if (status.get('visibility') === 'private') {
|
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||||
reblogIcon = 'lock';
|
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||||
}
|
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||||
|
const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
|
||||||
|
|
||||||
if (['private', 'direct'].includes(status.get('visibility'))) {
|
if (['private', 'direct'].includes(status.get('visibility'))) {
|
||||||
reblogLink = <Icon id={reblogIcon} />;
|
reblogLink = '';
|
||||||
} else if (this.context.router) {
|
} else if (this.context.router) {
|
||||||
reblogLink = (
|
reblogLink = (
|
||||||
|
<React.Fragment>
|
||||||
|
<React.Fragment> · </React.Fragment>
|
||||||
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||||
<Icon id={reblogIcon} />
|
<Icon id={reblogIcon} />
|
||||||
<span className='detailed-status__reblogs'>
|
<span className='detailed-status__reblogs'>
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
reblogLink = (
|
reblogLink = (
|
||||||
|
<React.Fragment>
|
||||||
|
<React.Fragment> · </React.Fragment>
|
||||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||||
<Icon id={reblogIcon} />
|
<Icon id={reblogIcon} />
|
||||||
<span className='detailed-status__reblogs'>
|
<span className='detailed-status__reblogs'>
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,7 +237,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
|
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
|
||||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
|
||||||
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
|
||||||
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
|
||||||
@ -234,7 +250,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||||||
<div className='detailed-status__meta'>
|
<div className='detailed-status__meta'>
|
||||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
||||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||||
</a>{applicationLink} · {reblogLink} · {favouriteLink}{localOnly}
|
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}{localOnly}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user