Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - `.env.production.sample`: Upstream changed it completely. Changed ours to merge upstream's new structure, but keeping most of the information.
This commit is contained in:
commit
2d8be0a6e1
|
@ -1,27 +1,15 @@
|
||||||
# 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/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)
|
|
||||||
|
|
||||||
# Use this only if you need to run mastodon on a different domain than the one used for federation.
|
# Use this only if you need to run mastodon on a different domain than the one used for federation.
|
||||||
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md
|
# 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.
|
# DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING.
|
||||||
|
@ -32,107 +20,99 @@ LOCAL_DOMAIN=example.com
|
||||||
# be added. Comma separated values
|
# be added. Comma separated values
|
||||||
# ALTERNATE_DOMAINS=example1.com,example2.com
|
# ALTERNATE_DOMAINS=example1.com,example2.com
|
||||||
|
|
||||||
# Application secrets
|
# 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
|
||||||
|
|
||||||
|
# Limited federation mode (optional)
|
||||||
|
# Only allow federation with specific domains, see
|
||||||
|
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
|
||||||
|
# LIMITED_FEDERATION_MODE=true
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
# -----
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
# ----------
|
||||||
|
DB_HOST=/var/run/postgresql
|
||||||
|
DB_USER=mastodon
|
||||||
|
DB_NAME=mastodon_production
|
||||||
|
DB_PASS=
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
|
||||||
|
# ElasticSearch (optional)
|
||||||
|
# ------------------------
|
||||||
|
#ES_ENABLED=true
|
||||||
|
#ES_HOST=localhost
|
||||||
|
#ES_PORT=9200
|
||||||
|
|
||||||
|
|
||||||
|
# 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)
|
# 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)
|
||||||
|
# -------
|
||||||
SECRET_KEY_BASE=
|
SECRET_KEY_BASE=
|
||||||
OTP_SECRET=
|
OTP_SECRET=
|
||||||
|
|
||||||
# VAPID keys (used for push notifications
|
|
||||||
# You can generate the keys using the following command (first is the private key, second is the public one)
|
# Web Push
|
||||||
|
# --------
|
||||||
|
# Generate with `rake mastodon:webpush:generate_vapid_key` (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
|
# You should only generate this once per instance. If you later decide to change it, all push subscription will
|
||||||
# be invalidated, requiring the users to access the website again to resubscribe.
|
# 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
|
# Registrations
|
||||||
|
# -------------
|
||||||
|
|
||||||
# Single user mode will disable registrations and redirect frontpage to the first profile
|
# Single user mode will disable registrations and redirect frontpage to the first profile
|
||||||
# SINGLE_USER_MODE=true
|
# SINGLE_USER_MODE=true
|
||||||
|
|
||||||
# Prevent registrations with following e-mail domains
|
# Prevent registrations with following e-mail domains
|
||||||
# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc
|
# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc
|
||||||
|
|
||||||
# Only allow registrations with the following e-mail domains
|
# Only allow registrations with the following e-mail domains
|
||||||
# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc
|
# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc
|
||||||
|
|
||||||
|
#TODO move this
|
||||||
# Optionally change default language
|
# Optionally change default language
|
||||||
# DEFAULT_LOCALE=de
|
# DEFAULT_LOCALE=de
|
||||||
|
|
||||||
# E-mail configuration
|
|
||||||
# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers
|
# Sending mail
|
||||||
# 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.
|
|
||||||
# PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system
|
|
||||||
# PAPERCLIP_ROOT_URL=/system
|
|
||||||
|
|
||||||
# Optional asset host for multi-server setups
|
# File storage (optional)
|
||||||
# The asset 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://example.com/
|
|
||||||
# CDN_HOST=https://assets.example.com
|
|
||||||
|
|
||||||
# Optional list of hosts that are allowed to serve media for your instance
|
|
||||||
# This is useful if you include external media in your custom CSS or about page,
|
|
||||||
# or if your data storage provider makes use of redirects to other domains.
|
|
||||||
# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com
|
|
||||||
|
|
||||||
# S3 (optional)
|
|
||||||
# The attachment host must allow cross origin request from WEB_DOMAIN or
|
# 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
|
# LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the
|
||||||
# following header field:
|
# following header field:
|
||||||
# Access-Control-Allow-Origin: https://192.168.1.123:9000/
|
# Access-Control-Allow-Origin: https://192.168.1.123:9000/
|
||||||
|
# -----------------------
|
||||||
#S3_ENABLED=true
|
#S3_ENABLED=true
|
||||||
# S3_BUCKET=
|
#S3_BUCKET=files.example.com
|
||||||
#AWS_ACCESS_KEY_ID=
|
#AWS_ACCESS_KEY_ID=
|
||||||
#AWS_SECRET_ACCESS_KEY=
|
#AWS_SECRET_ACCESS_KEY=
|
||||||
# S3_REGION=
|
#S3_ALIAS_HOST=files.example.com
|
||||||
# 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)
|
# Swift (optional)
|
||||||
# The attachment host must allow cross origin request - see the description
|
# The attachment host must allow cross origin request - see the description
|
||||||
|
@ -155,50 +135,27 @@ SMTP_FROM_ADDRESS=notifications@example.com
|
||||||
# Defaults to 60 seconds. Set to 0 to disable
|
# Defaults to 60 seconds. Set to 0 to disable
|
||||||
# SWIFT_CACHE_TTL=
|
# SWIFT_CACHE_TTL=
|
||||||
|
|
||||||
|
# Optional asset host for multi-server setups
|
||||||
|
# The asset 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://example.com/
|
||||||
|
# CDN_HOST=https://assets.example.com
|
||||||
|
|
||||||
|
# Optional list of hosts that are allowed to serve media for your instance
|
||||||
|
# This is useful if you include external media in your custom CSS or about page,
|
||||||
|
# or if your data storage provider makes use of redirects to other domains.
|
||||||
|
# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com
|
||||||
|
|
||||||
# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare)
|
# Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare)
|
||||||
# S3_ALIAS_HOST=
|
# S3_ALIAS_HOST=
|
||||||
|
|
||||||
# Streaming API integration
|
# Streaming API integration
|
||||||
# STREAMING_API_BASE_URL=
|
# 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
|
|
||||||
|
|
||||||
# Maximum number of pinned posts
|
|
||||||
# MAX_PINNED_TOOTS=5
|
|
||||||
|
|
||||||
# Maximum allowed bio characters
|
|
||||||
# MAX_BIO_CHARS=500
|
|
||||||
|
|
||||||
# Maximim number of profile fields allowed
|
|
||||||
# MAX_PROFILE_FIELDS=4
|
|
||||||
|
|
||||||
# Maximum allowed display name characters
|
|
||||||
# MAX_DISPLAY_NAME_CHARS=30
|
|
||||||
|
|
||||||
# Maximum image and video/audio upload sizes
|
|
||||||
# Units are in bytes
|
|
||||||
# 1048576 bytes equals 1 megabyte
|
|
||||||
# MAX_IMAGE_SIZE=8388608
|
|
||||||
# MAX_VIDEO_SIZE=41943040
|
|
||||||
|
|
||||||
# Maximum search results to display
|
|
||||||
# Only relevant when elasticsearch is installed
|
|
||||||
# MAX_SEARCH_RESULTS=20
|
|
||||||
|
|
||||||
|
# External authentication (optional)
|
||||||
|
# ----------------------------------
|
||||||
# LDAP authentication (optional)
|
# LDAP authentication (optional)
|
||||||
# LDAP_ENABLED=true
|
# LDAP_ENABLED=true
|
||||||
# LDAP_HOST=localhost
|
# LDAP_HOST=localhost
|
||||||
|
@ -276,17 +233,33 @@ STREAMING_CLUSTER_NUM=1
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
|
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
|
||||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
|
# 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)
|
# Custom settings
|
||||||
# Require remote servers to authentify when fetching toots, see
|
# ---------------
|
||||||
# https://docs.joinmastodon.org/admin/config/#authorized_fetch
|
# Various ways to customize Mastodon's behavior
|
||||||
# AUTHORIZED_FETCH=true
|
# ---------------
|
||||||
|
|
||||||
# Limited federation mode (optional)
|
# Maximum allowed character count
|
||||||
# Only allow federation with specific domains, see
|
MAX_TOOT_CHARS=500
|
||||||
# https://docs.joinmastodon.org/admin/config/#whitelist_mode
|
|
||||||
# LIMITED_FEDERATION_MODE=true
|
# Maximum number of pinned posts
|
||||||
|
MAX_PINNED_TOOTS=5
|
||||||
|
|
||||||
|
# Maximum allowed bio characters
|
||||||
|
MAX_BIO_CHARS=500
|
||||||
|
|
||||||
|
# Maximim number of profile fields allowed
|
||||||
|
MAX_PROFILE_FIELDS=4
|
||||||
|
|
||||||
|
# Maximum allowed display name characters
|
||||||
|
MAX_DISPLAY_NAME_CHARS=30
|
||||||
|
|
||||||
|
# Maximum image and video/audio upload sizes
|
||||||
|
# Units are in bytes
|
||||||
|
# 1048576 bytes equals 1 megabyte
|
||||||
|
# MAX_IMAGE_SIZE=8388608
|
||||||
|
# MAX_VIDEO_SIZE=41943040
|
||||||
|
|
||||||
|
# Maximum search results to display
|
||||||
|
# Only relevant when elasticsearch is installed
|
||||||
|
# MAX_SEARCH_RESULTS=20
|
||||||
|
|
1
Gemfile
1
Gemfile
|
@ -48,6 +48,7 @@ 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 'ed25519', '~> 1.2'
|
||||||
|
|
|
@ -165,6 +165,7 @@ GEM
|
||||||
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.3)
|
coderay (1.1.3)
|
||||||
|
color_diff (0.1)
|
||||||
concurrent-ruby (1.1.6)
|
concurrent-ruby (1.1.6)
|
||||||
connection_pool (2.2.3)
|
connection_pool (2.2.3)
|
||||||
crack (0.4.3)
|
crack (0.4.3)
|
||||||
|
@ -690,6 +691,7 @@ DEPENDENCIES
|
||||||
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)
|
||||||
|
|
|
@ -353,7 +353,9 @@ class Status extends ImmutablePureComponent {
|
||||||
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'])}
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
blurhash={attachment.get('blurhash')}
|
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)}
|
||||||
width={this.props.cachedMediaWidth}
|
width={this.props.cachedMediaWidth}
|
||||||
height={110}
|
height={110}
|
||||||
|
|
|
@ -5,131 +5,12 @@ 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 { encode, decode } from 'blurhash';
|
|
||||||
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
const digitCharacters = [
|
const hex2rgba = (hex, alpha = 1) => {
|
||||||
'0',
|
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
||||||
'1',
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
'2',
|
|
||||||
'3',
|
|
||||||
'4',
|
|
||||||
'5',
|
|
||||||
'6',
|
|
||||||
'7',
|
|
||||||
'8',
|
|
||||||
'9',
|
|
||||||
'A',
|
|
||||||
'B',
|
|
||||||
'C',
|
|
||||||
'D',
|
|
||||||
'E',
|
|
||||||
'F',
|
|
||||||
'G',
|
|
||||||
'H',
|
|
||||||
'I',
|
|
||||||
'J',
|
|
||||||
'K',
|
|
||||||
'L',
|
|
||||||
'M',
|
|
||||||
'N',
|
|
||||||
'O',
|
|
||||||
'P',
|
|
||||||
'Q',
|
|
||||||
'R',
|
|
||||||
'S',
|
|
||||||
'T',
|
|
||||||
'U',
|
|
||||||
'V',
|
|
||||||
'W',
|
|
||||||
'X',
|
|
||||||
'Y',
|
|
||||||
'Z',
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
'c',
|
|
||||||
'd',
|
|
||||||
'e',
|
|
||||||
'f',
|
|
||||||
'g',
|
|
||||||
'h',
|
|
||||||
'i',
|
|
||||||
'j',
|
|
||||||
'k',
|
|
||||||
'l',
|
|
||||||
'm',
|
|
||||||
'n',
|
|
||||||
'o',
|
|
||||||
'p',
|
|
||||||
'q',
|
|
||||||
'r',
|
|
||||||
's',
|
|
||||||
't',
|
|
||||||
'u',
|
|
||||||
'v',
|
|
||||||
'w',
|
|
||||||
'x',
|
|
||||||
'y',
|
|
||||||
'z',
|
|
||||||
'#',
|
|
||||||
'$',
|
|
||||||
'%',
|
|
||||||
'*',
|
|
||||||
'+',
|
|
||||||
',',
|
|
||||||
'-',
|
|
||||||
'.',
|
|
||||||
':',
|
|
||||||
';',
|
|
||||||
'=',
|
|
||||||
'?',
|
|
||||||
'@',
|
|
||||||
'[',
|
|
||||||
']',
|
|
||||||
'^',
|
|
||||||
'_',
|
|
||||||
'{',
|
|
||||||
'|',
|
|
||||||
'}',
|
|
||||||
'~',
|
|
||||||
];
|
|
||||||
|
|
||||||
const decode83 = (str) => {
|
|
||||||
let value = 0;
|
|
||||||
let c, digit;
|
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
c = str[i];
|
|
||||||
digit = digitCharacters.indexOf(c);
|
|
||||||
value = value * 83 + digit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeRGB = int => ({
|
|
||||||
r: Math.max(0, (int >> 16)),
|
|
||||||
g: Math.max(0, (int >> 8) & 255),
|
|
||||||
b: Math.max(0, (int & 255)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const luma = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
|
|
||||||
const adjustColor = ({ r, g, b }, lumaThreshold = 100) => {
|
|
||||||
let delta;
|
|
||||||
|
|
||||||
if (luma({ r, g, b }) >= lumaThreshold) {
|
|
||||||
delta = -80;
|
|
||||||
} else {
|
|
||||||
delta = 80;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
r: r + delta,
|
|
||||||
g: g + delta,
|
|
||||||
b: b + delta,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -157,7 +38,9 @@ class Audio extends React.PureComponent {
|
||||||
fullscreen: PropTypes.bool,
|
fullscreen: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
cacheWidth: PropTypes.func,
|
cacheWidth: PropTypes.func,
|
||||||
blurhash: PropTypes.string,
|
backgroundColor: PropTypes.string,
|
||||||
|
foregroundColor: PropTypes.string,
|
||||||
|
accentColor: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -169,7 +52,6 @@ class Audio extends React.PureComponent {
|
||||||
muted: false,
|
muted: false,
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
dragging: false,
|
dragging: false,
|
||||||
color: { r: 255, g: 255, b: 255 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setPlayerRef = c => {
|
setPlayerRef = c => {
|
||||||
|
@ -207,10 +89,6 @@ class Audio extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlurhashCanvasRef = c => {
|
|
||||||
this.blurhashCanvas = c;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanvasRef = c => {
|
setCanvasRef = c => {
|
||||||
this.canvas = c;
|
this.canvas = c;
|
||||||
|
|
||||||
|
@ -222,41 +100,13 @@ class Audio extends React.PureComponent {
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
window.addEventListener('scroll', this.handleScroll);
|
window.addEventListener('scroll', this.handleScroll);
|
||||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||||
|
|
||||||
if (!this.props.blurhash) {
|
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'anonymous';
|
|
||||||
img.onload = () => this.handlePosterLoad(img);
|
|
||||||
img.src = this.props.poster;
|
|
||||||
} else {
|
|
||||||
this._setColorScheme();
|
|
||||||
this._decodeBlurhash();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
componentDidUpdate (prevProps, prevState) {
|
||||||
if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
|
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height) {
|
||||||
const img = new Image();
|
|
||||||
img.crossOrigin = 'anonymous';
|
|
||||||
img.onload = () => this.handlePosterLoad(img);
|
|
||||||
img.src = this.props.poster;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
|
|
||||||
this._setColorScheme();
|
|
||||||
this._decodeBlurhash();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._clear();
|
this._clear();
|
||||||
this._draw();
|
this._draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
_decodeBlurhash () {
|
|
||||||
const context = this.blurhashCanvas.getContext('2d');
|
|
||||||
const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
|
|
||||||
const outputImageData = new ImageData(pixels, 32, 32);
|
|
||||||
|
|
||||||
context.putImageData(outputImageData, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
@ -425,31 +275,6 @@ class Audio extends React.PureComponent {
|
||||||
this.analyser = analyser;
|
this.analyser = analyser;
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePosterLoad = image => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
|
|
||||||
canvas.width = image.width;
|
|
||||||
canvas.height = image.height;
|
|
||||||
|
|
||||||
context.drawImage(image, 0, 0);
|
|
||||||
|
|
||||||
const inputImageData = context.getImageData(0, 0, image.width, image.height);
|
|
||||||
const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
|
|
||||||
|
|
||||||
this.setState({ blurhash });
|
|
||||||
}
|
|
||||||
|
|
||||||
_setColorScheme () {
|
|
||||||
const blurhash = this.props.blurhash || this.state.blurhash;
|
|
||||||
const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
color: adjustColor(averageColor),
|
|
||||||
darkText: luma(averageColor) >= 165,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDownload = () => {
|
handleDownload = () => {
|
||||||
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
||||||
const element = document.createElement('a');
|
const element = document.createElement('a');
|
||||||
|
@ -609,8 +434,8 @@ class Audio extends React.PureComponent {
|
||||||
|
|
||||||
const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
|
const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
|
||||||
|
|
||||||
const mainColor = `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
|
const mainColor = this._getAccentColor();
|
||||||
const lastColor = `rgba(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b}, 0)`;
|
const lastColor = hex2rgba(mainColor, 0);
|
||||||
|
|
||||||
gradient.addColorStop(0, mainColor);
|
gradient.addColorStop(0, mainColor);
|
||||||
gradient.addColorStop(0.6, mainColor);
|
gradient.addColorStop(0.6, mainColor);
|
||||||
|
@ -632,17 +457,25 @@ class Audio extends React.PureComponent {
|
||||||
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
|
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
|
||||||
}
|
}
|
||||||
|
|
||||||
_getColor () {
|
_getAccentColor () {
|
||||||
return `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
|
return this.props.accentColor || '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
_getBackgroundColor () {
|
||||||
|
return this.props.backgroundColor || '#000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
_getForegroundColor () {
|
||||||
|
return this.props.foregroundColor || '#ffffff';
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { src, intl, alt, editable } = this.props;
|
const { src, intl, alt, editable } = this.props;
|
||||||
const { paused, muted, volume, currentTime, duration, buffer, darkText, dragging } = this.state;
|
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
|
||||||
const progress = (currentTime / duration) * 100;
|
const progress = (currentTime / duration) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<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}>
|
||||||
<audio
|
<audio
|
||||||
src={src}
|
src={src}
|
||||||
ref={this.setAudioRef}
|
ref={this.setAudioRef}
|
||||||
|
@ -654,24 +487,15 @@ class Audio extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<canvas
|
<canvas
|
||||||
className='audio-player__background'
|
|
||||||
onClick={this.togglePlay}
|
|
||||||
width='32'
|
|
||||||
height='32'
|
|
||||||
style={{ width: this.state.width, height: this.state.height, position: 'absolute', top: 0, left: 0 }}
|
|
||||||
ref={this.setBlurhashCanvasRef}
|
|
||||||
aria-label={alt}
|
|
||||||
title={alt}
|
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex='0'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<canvas
|
|
||||||
className='audio-player__canvas'
|
className='audio-player__canvas'
|
||||||
width={this.state.width}
|
width={this.state.width}
|
||||||
height={this.state.height}
|
height={this.state.height}
|
||||||
style={{ width: '100%', position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
|
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
||||||
ref={this.setCanvasRef}
|
ref={this.setCanvasRef}
|
||||||
|
onClick={this.togglePlay}
|
||||||
|
title={alt}
|
||||||
|
aria-label={alt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
@ -684,12 +508,12 @@ class Audio extends React.PureComponent {
|
||||||
|
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
<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__buffer' style={{ width: `${buffer}%` }} />
|
||||||
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getColor() }} />
|
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
className={classNames('video-player__seek__handle', { active: dragging })}
|
||||||
tabIndex='0'
|
tabIndex='0'
|
||||||
style={{ left: `${progress}%`, backgroundColor: this._getColor() }}
|
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -700,12 +524,12 @@ class Audio extends React.PureComponent {
|
||||||
<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={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
<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._getColor() }} />
|
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={classNames('video-player__volume__handle')}
|
className={classNames('video-player__volume__handle')}
|
||||||
tabIndex='0'
|
tabIndex='0'
|
||||||
style={{ left: `${volume * 100}%`, backgroundColor: this._getColor() }}
|
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -126,7 +126,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
alt={attachment.get('description')}
|
alt={attachment.get('description')}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
blurhash={attachment.get('blurhash')}
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
height={150}
|
height={150}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -59,8 +59,11 @@ export default class AudioModal extends ImmutablePureComponent {
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
alt={media.get('description')}
|
alt={media.get('description')}
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
height={135}
|
height={150}
|
||||||
preload
|
poster={media.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||||
|
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import CharacterCounter from 'mastodon/features/compose/components/character_cou
|
||||||
import { length } from 'stringz';
|
import { length } from 'stringz';
|
||||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||||
import GIFV from 'mastodon/components/gifv';
|
import GIFV from 'mastodon/components/gifv';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
|
@ -26,6 +27,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
const mapStateToProps = (state, { id }) => ({
|
||||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||||
|
account: state.getIn(['accounts', me]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||||
|
@ -78,6 +80,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -233,7 +236,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, intl, onClose } = this.props;
|
const { media, intl, account, onClose } = this.props;
|
||||||
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
|
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
|
||||||
|
|
||||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||||
|
@ -325,7 +328,10 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
src={media.get('url')}
|
src={media.get('url')}
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
height={150}
|
height={150}
|
||||||
preload
|
poster={media.get('preview_url') || account.get('avatar_static')}
|
||||||
|
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
||||||
|
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
||||||
|
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
||||||
editable
|
editable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
"confirmations.block.confirm": "Block",
|
"confirmations.block.confirm": "Block",
|
||||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||||
"confirmations.delete.confirm": "Delete",
|
"confirmations.delete.confirm": "Delete",
|
||||||
"confirmations.delete.message": "Are you sure you want to delete this status?",
|
"confirmations.delete.message": "Are you sure you want to delete this toot?",
|
||||||
"confirmations.delete_list.confirm": "Delete",
|
"confirmations.delete_list.confirm": "Delete",
|
||||||
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
"confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
|
||||||
"confirmations.domain_block.confirm": "Block entire domain",
|
"confirmations.domain_block.confirm": "Block entire domain",
|
||||||
|
@ -124,7 +124,7 @@
|
||||||
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
|
"confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
|
||||||
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
"confirmations.mute.message": "Are you sure you want to mute {name}?",
|
||||||
"confirmations.redraft.confirm": "Delete & redraft",
|
"confirmations.redraft.confirm": "Delete & redraft",
|
||||||
"confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
|
"confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
|
||||||
"confirmations.reply.confirm": "Reply",
|
"confirmations.reply.confirm": "Reply",
|
||||||
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
"confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
|
||||||
"confirmations.unfollow.confirm": "Unfollow",
|
"confirmations.unfollow.confirm": "Unfollow",
|
||||||
|
@ -137,7 +137,7 @@
|
||||||
"directory.local": "From {domain} only",
|
"directory.local": "From {domain} only",
|
||||||
"directory.new_arrivals": "New arrivals",
|
"directory.new_arrivals": "New arrivals",
|
||||||
"directory.recently_active": "Recently active",
|
"directory.recently_active": "Recently active",
|
||||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
"embed.instructions": "Embed this toot on your website by copying the code below.",
|
||||||
"embed.preview": "Here is what it will look like:",
|
"embed.preview": "Here is what it will look like:",
|
||||||
"emoji_button.activity": "Activity",
|
"emoji_button.activity": "Activity",
|
||||||
"emoji_button.custom": "Custom",
|
"emoji_button.custom": "Custom",
|
||||||
|
@ -166,7 +166,7 @@
|
||||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||||
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
|
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
|
||||||
"empty_column.home.public_timeline": "the public timeline",
|
"empty_column.home.public_timeline": "the public timeline",
|
||||||
"empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
|
"empty_column.list": "There is nothing in this list yet. When members of this list post new toots, they will appear here.",
|
||||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||||
"empty_column.mutes": "You haven't muted any users yet.",
|
"empty_column.mutes": "You haven't muted any users yet.",
|
||||||
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
|
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
|
||||||
|
@ -223,12 +223,12 @@
|
||||||
"keyboard_shortcuts.back": "to navigate back",
|
"keyboard_shortcuts.back": "to navigate back",
|
||||||
"keyboard_shortcuts.blocked": "to open blocked users list",
|
"keyboard_shortcuts.blocked": "to open blocked users list",
|
||||||
"keyboard_shortcuts.boost": "to boost",
|
"keyboard_shortcuts.boost": "to boost",
|
||||||
"keyboard_shortcuts.column": "to focus a status in one of the columns",
|
"keyboard_shortcuts.column": "to focus a toot in one of the columns",
|
||||||
"keyboard_shortcuts.compose": "to focus the compose textarea",
|
"keyboard_shortcuts.compose": "to focus the compose textarea",
|
||||||
"keyboard_shortcuts.description": "Description",
|
"keyboard_shortcuts.description": "Description",
|
||||||
"keyboard_shortcuts.direct": "to open direct messages column",
|
"keyboard_shortcuts.direct": "to open direct messages column",
|
||||||
"keyboard_shortcuts.down": "to move down in the list",
|
"keyboard_shortcuts.down": "to move down in the list",
|
||||||
"keyboard_shortcuts.enter": "to open status",
|
"keyboard_shortcuts.enter": "to open toot",
|
||||||
"keyboard_shortcuts.favourite": "to favourite",
|
"keyboard_shortcuts.favourite": "to favourite",
|
||||||
"keyboard_shortcuts.favourites": "to open favourites list",
|
"keyboard_shortcuts.favourites": "to open favourites list",
|
||||||
"keyboard_shortcuts.federated": "to open federated timeline",
|
"keyboard_shortcuts.federated": "to open federated timeline",
|
||||||
|
@ -269,7 +269,7 @@
|
||||||
"lists.subheading": "Your lists",
|
"lists.subheading": "Your lists",
|
||||||
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
"load_pending": "{count, plural, one {# new item} other {# new items}}",
|
||||||
"loading_indicator.label": "Loading...",
|
"loading_indicator.label": "Loading...",
|
||||||
"media_gallery.toggle_visible": "Hide media",
|
"media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
|
||||||
"missing_indicator.label": "Not found",
|
"missing_indicator.label": "Not found",
|
||||||
"missing_indicator.sublabel": "This resource could not be found",
|
"missing_indicator.sublabel": "This resource could not be found",
|
||||||
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
"mute_modal.hide_notifications": "Hide notifications from this user?",
|
||||||
|
@ -297,13 +297,13 @@
|
||||||
"navigation_bar.preferences": "Preferences",
|
"navigation_bar.preferences": "Preferences",
|
||||||
"navigation_bar.public_timeline": "Federated timeline",
|
"navigation_bar.public_timeline": "Federated timeline",
|
||||||
"navigation_bar.security": "Security",
|
"navigation_bar.security": "Security",
|
||||||
"notification.favourite": "{name} favourited your status",
|
"notification.favourite": "{name} favourited your toot",
|
||||||
"notification.follow": "{name} followed you",
|
"notification.follow": "{name} followed you",
|
||||||
"notification.follow_request": "{name} has requested to follow you",
|
"notification.follow_request": "{name} has requested to follow you",
|
||||||
"notification.mention": "{name} mentioned you",
|
"notification.mention": "{name} mentioned you",
|
||||||
"notification.own_poll": "Your poll has ended",
|
"notification.own_poll": "Your poll has ended",
|
||||||
"notification.poll": "A poll you have voted in has ended",
|
"notification.poll": "A poll you have voted in has ended",
|
||||||
"notification.reblog": "{name} boosted your status",
|
"notification.reblog": "{name} boosted your toot",
|
||||||
"notifications.clear": "Clear notifications",
|
"notifications.clear": "Clear notifications",
|
||||||
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
"notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
|
||||||
"notifications.column_settings.alert": "Desktop notifications",
|
"notifications.column_settings.alert": "Desktop notifications",
|
||||||
|
@ -334,7 +334,7 @@
|
||||||
"poll.voted": "You voted for this answer",
|
"poll.voted": "You voted for this answer",
|
||||||
"poll_button.add_poll": "Add a poll",
|
"poll_button.add_poll": "Add a poll",
|
||||||
"poll_button.remove_poll": "Remove poll",
|
"poll_button.remove_poll": "Remove poll",
|
||||||
"privacy.change": "Adjust status privacy",
|
"privacy.change": "Adjust toot privacy",
|
||||||
"privacy.direct.long": "Visible for mentioned users only",
|
"privacy.direct.long": "Visible for mentioned users only",
|
||||||
"privacy.direct.short": "Direct",
|
"privacy.direct.short": "Direct",
|
||||||
"privacy.private.long": "Visible for followers only",
|
"privacy.private.long": "Visible for followers only",
|
||||||
|
@ -361,9 +361,9 @@
|
||||||
"report.target": "Reporting {target}",
|
"report.target": "Reporting {target}",
|
||||||
"search.placeholder": "Search",
|
"search.placeholder": "Search",
|
||||||
"search_popout.search_format": "Advanced search format",
|
"search_popout.search_format": "Advanced search format",
|
||||||
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
|
"search_popout.tips.full_text": "Simple text returns toots you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
|
||||||
"search_popout.tips.hashtag": "hashtag",
|
"search_popout.tips.hashtag": "hashtag",
|
||||||
"search_popout.tips.status": "status",
|
"search_popout.tips.status": "toot",
|
||||||
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
|
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
|
||||||
"search_popout.tips.user": "user",
|
"search_popout.tips.user": "user",
|
||||||
"search_results.accounts": "People",
|
"search_results.accounts": "People",
|
||||||
|
@ -372,7 +372,7 @@
|
||||||
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
|
"search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
|
||||||
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
|
||||||
"status.admin_account": "Open moderation interface for @{name}",
|
"status.admin_account": "Open moderation interface for @{name}",
|
||||||
"status.admin_status": "Open this status in the moderation interface",
|
"status.admin_status": "Open this toot in the moderation interface",
|
||||||
"status.block": "Block @{name}",
|
"status.block": "Block @{name}",
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
"status.cancel_reblog_private": "Unboost",
|
"status.cancel_reblog_private": "Unboost",
|
||||||
|
@ -390,7 +390,7 @@
|
||||||
"status.more": "More",
|
"status.more": "More",
|
||||||
"status.mute": "Mute @{name}",
|
"status.mute": "Mute @{name}",
|
||||||
"status.mute_conversation": "Mute conversation",
|
"status.mute_conversation": "Mute conversation",
|
||||||
"status.open": "Expand this status",
|
"status.open": "Expand this toot",
|
||||||
"status.pin": "Pin on profile",
|
"status.pin": "Pin on profile",
|
||||||
"status.pinned": "Pinned toot",
|
"status.pinned": "Pinned toot",
|
||||||
"status.read_more": "Read more",
|
"status.read_more": "Read more",
|
||||||
|
@ -433,7 +433,7 @@
|
||||||
"trends.trending_now": "Trending now",
|
"trends.trending_now": "Trending now",
|
||||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||||
"upload_area.title": "Drag & drop to upload",
|
"upload_area.title": "Drag & drop to upload",
|
||||||
"upload_button.label": "Add media ({formats})",
|
"upload_button.label": "Add images, a video or an audio file",
|
||||||
"upload_error.limit": "File upload limit exceeded.",
|
"upload_error.limit": "File upload limit exceeded.",
|
||||||
"upload_error.poll": "File upload not allowed with polls.",
|
"upload_error.poll": "File upload not allowed with polls.",
|
||||||
"upload_form.audio_description": "Describe for people with hearing loss",
|
"upload_form.audio_description": "Describe for people with hearing loss",
|
||||||
|
|
|
@ -5314,36 +5314,31 @@ a.status-card.compact:hover {
|
||||||
|
|
||||||
.video-player__volume::before,
|
.video-player__volume::before,
|
||||||
.video-player__seek::before {
|
.video-player__seek::before {
|
||||||
background: rgba($white, 0.15);
|
background: currentColor;
|
||||||
}
|
opacity: 0.15;
|
||||||
|
|
||||||
&.with-light-background {
|
|
||||||
color: $black;
|
|
||||||
|
|
||||||
.video-player__volume::before,
|
|
||||||
.video-player__seek::before {
|
|
||||||
background: rgba($black, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player__seek__buffer {
|
.video-player__seek__buffer {
|
||||||
background: rgba($black, 0.2);
|
background: currentColor;
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player__buttons button {
|
.video-player__buttons button {
|
||||||
color: rgba($black, 0.75);
|
color: currentColor;
|
||||||
|
opacity: 0.75;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
color: $black;
|
color: currentColor;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player__time-sep,
|
.video-player__time-sep,
|
||||||
.video-player__time-total,
|
.video-player__time-total,
|
||||||
.video-player__time-current {
|
.video-player__time-current {
|
||||||
color: $black;
|
color: currentColor;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-player__seek::before,
|
.video-player__seek::before,
|
||||||
|
|
|
@ -40,6 +40,13 @@ class MediaAttachment < ApplicationRecord
|
||||||
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
|
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
|
||||||
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
|
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
|
||||||
|
|
||||||
|
META_KEYS = %i(
|
||||||
|
focus
|
||||||
|
colors
|
||||||
|
original
|
||||||
|
small
|
||||||
|
).freeze
|
||||||
|
|
||||||
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
|
IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze
|
||||||
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
|
VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze
|
||||||
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
|
VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze
|
||||||
|
@ -165,7 +172,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
|
|
||||||
has_attached_file :thumbnail,
|
has_attached_file :thumbnail,
|
||||||
styles: THUMBNAIL_STYLES,
|
styles: THUMBNAIL_STYLES,
|
||||||
processors: [:lazy_thumbnail, :blurhash_transcoder],
|
processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor],
|
||||||
convert_options: GLOBAL_CONVERT_OPTIONS
|
convert_options: GLOBAL_CONVERT_OPTIONS
|
||||||
|
|
||||||
validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
|
validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
|
||||||
|
@ -216,7 +223,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
|
|
||||||
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
|
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
|
||||||
|
|
||||||
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
|
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(*META_KEYS)
|
||||||
meta['focus'] = { 'x' => x, 'y' => y }
|
meta['focus'] = { 'x' => x, 'y' => y }
|
||||||
|
|
||||||
file.instance_write(:meta, meta)
|
file.instance_write(:meta, meta)
|
||||||
|
@ -338,7 +345,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def populate_meta
|
def populate_meta
|
||||||
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
|
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(*META_KEYS)
|
||||||
|
|
||||||
file.queued_for_write.each do |style, file|
|
file.queued_for_write.each do |style, file|
|
||||||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||||
|
|
|
@ -11,6 +11,6 @@
|
||||||
%video{ autoplay: 'autoplay', muted: 'muted', loop: 'loop' }
|
%video{ autoplay: 'autoplay', muted: 'muted', loop: 'loop' }
|
||||||
%source{ src: @media_attachment.file.url(:original) }
|
%source{ src: @media_attachment.file.url(:original) }
|
||||||
- elsif @media_attachment.audio?
|
- elsif @media_attachment.audio?
|
||||||
= react_component :audio, src: @media_attachment.file.url(:original), poster: full_asset_url(@media_attachment.account.avatar_static_url), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do
|
= react_component :audio, src: @media_attachment.file.url(:original), poster: @media_attachment.thumbnail.present? ? @media_attachment.thumbnail.url : @media_attachment.account.avatar_static_url, backgroundColor: @media_attachment.file.meta.dig('colors', 'background'), foregroundColor: @media_attachment.file.meta.dig('colors', 'foreground'), accentColor: @media_attachment.file.meta.dig('colors', 'accent'), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do
|
||||||
%audio{ controls: 'controls' }
|
%audio{ controls: 'controls' }
|
||||||
%source{ src: @media_attachment.file.url(:original) }
|
%source{ src: @media_attachment.file.url(:original) }
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- elsif status.media_attachments.first.audio?
|
- elsif status.media_attachments.first.audio?
|
||||||
- audio = status.media_attachments.first
|
- audio = status.media_attachments.first
|
||||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- elsif status.media_attachments.first.audio?
|
- elsif status.media_attachments.first.audio?
|
||||||
- audio = status.media_attachments.first
|
- audio = status.media_attachments.first
|
||||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||||
- else
|
- else
|
||||||
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||||
|
|
|
@ -32,7 +32,7 @@ class PostProcessMediaWorker
|
||||||
|
|
||||||
media_attachment.file.reprocess!(:original)
|
media_attachment.file.reprocess!(:original)
|
||||||
media_attachment.processing = :complete
|
media_attachment.processing = :complete
|
||||||
media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(:focus, :original, :small)
|
media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(*MediaAttachment::META_KEYS)
|
||||||
media_attachment.save
|
media_attachment.save
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
|
|
|
@ -11,6 +11,7 @@ require_relative '../lib/redis/namespace_extensions'
|
||||||
require_relative '../lib/paperclip/url_generator_extensions'
|
require_relative '../lib/paperclip/url_generator_extensions'
|
||||||
require_relative '../lib/paperclip/attachment_extensions'
|
require_relative '../lib/paperclip/attachment_extensions'
|
||||||
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
|
require_relative '../lib/paperclip/media_type_spoof_detector_extensions'
|
||||||
|
require_relative '../lib/paperclip/transcoder_extensions'
|
||||||
require_relative '../lib/paperclip/lazy_thumbnail'
|
require_relative '../lib/paperclip/lazy_thumbnail'
|
||||||
require_relative '../lib/paperclip/gif_transcoder'
|
require_relative '../lib/paperclip/gif_transcoder'
|
||||||
require_relative '../lib/paperclip/video_transcoder'
|
require_relative '../lib/paperclip/video_transcoder'
|
||||||
|
|
|
@ -21,9 +21,7 @@ en:
|
||||||
federation_hint_html: With an account on %{instance} you'll be able to follow people on any Mastodon server and beyond.
|
federation_hint_html: With an account on %{instance} you'll be able to follow people on any Mastodon server and beyond.
|
||||||
get_apps: Try a mobile app
|
get_apps: Try a mobile app
|
||||||
hosted_on: Mastodon hosted on %{domain}
|
hosted_on: Mastodon hosted on %{domain}
|
||||||
instance_actor_flash: |
|
instance_actor_flash: This account is a virtual actor used to represent the server itself and not any individual user. It is used for federation purposes and should not be blocked unless you want to block the whole instance, in which case you should use a domain block.
|
||||||
This account is a virtual actor used to represent the server itself and not any individual user.
|
|
||||||
It is used for federation purposes and should not be blocked unless you want to block the whole instance, in which case you should use a domain block.
|
|
||||||
learn_more: Learn more
|
learn_more: Learn more
|
||||||
privacy_policy: Privacy policy
|
privacy_policy: Privacy policy
|
||||||
see_whats_happening: See what's happening
|
see_whats_happening: See what's happening
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'mime/types/columnar'
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
class ColorExtractor < Paperclip::Processor
|
||||||
|
MIN_CONTRAST = 3.0
|
||||||
|
FREQUENCY_THRESHOLD = 0.01
|
||||||
|
|
||||||
|
def make
|
||||||
|
depth = 8
|
||||||
|
|
||||||
|
# Determine background palette by getting colors close to the image's edge only
|
||||||
|
background_palette = palette_from_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||||
|
|
||||||
|
# Determine foreground palette from the whole image
|
||||||
|
foreground_palette = palette_from_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||||
|
|
||||||
|
background_color = background_palette.first || foreground_palette.first
|
||||||
|
foreground_colors = []
|
||||||
|
|
||||||
|
return @file if background_color.nil?
|
||||||
|
|
||||||
|
max_distance = 0
|
||||||
|
max_distance_color = nil
|
||||||
|
|
||||||
|
foreground_palette.each do |color|
|
||||||
|
distance = ColorDiff.between(background_color, color)
|
||||||
|
|
||||||
|
if distance > max_distance
|
||||||
|
max_distance = distance
|
||||||
|
max_distance_color = color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
foreground_colors << max_distance_color unless max_distance_color.nil?
|
||||||
|
|
||||||
|
max_distance = 0
|
||||||
|
max_distance_color = nil
|
||||||
|
|
||||||
|
foreground_palette.each do |color|
|
||||||
|
distance = ColorDiff.between(background_color, color)
|
||||||
|
contrast = w3c_contrast(background_color, color)
|
||||||
|
|
||||||
|
if distance > max_distance && contrast >= MIN_CONTRAST && !foreground_colors.include?(color)
|
||||||
|
max_distance = distance
|
||||||
|
max_distance_color = color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
foreground_colors << max_distance_color unless max_distance_color.nil?
|
||||||
|
|
||||||
|
# If we don't have enough colors for accent and foreground, generate
|
||||||
|
# new ones by manipulating the background color
|
||||||
|
(2 - foreground_colors.size).times do |i|
|
||||||
|
foreground_colors << lighten_or_darken(background_color, 35 + (15 * i))
|
||||||
|
end
|
||||||
|
|
||||||
|
# We want the color with the highest contrast to background to be the foreground one,
|
||||||
|
# and the one with the highest saturation to be the accent one
|
||||||
|
foreground_color = foreground_colors.max_by { |rgb| w3c_contrast(background_color, rgb) }
|
||||||
|
accent_color = foreground_colors.max_by { |rgb| rgb_to_hsl(rgb.r, rgb.g, rgb.b)[1] }
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
colors: {
|
||||||
|
background: rgb_to_hex(background_color),
|
||||||
|
foreground: rgb_to_hex(foreground_color),
|
||||||
|
accent: rgb_to_hex(accent_color),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment.instance.file.instance_write(:meta, (attachment.instance.file.instance_read(:meta) || {}).merge(meta))
|
||||||
|
|
||||||
|
@file
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def w3c_contrast(color1, color2)
|
||||||
|
luminance1 = (0.2126 * color1.r + 0.7152 * color1.g + 0.0722 * color1.b) + 0.05
|
||||||
|
luminance2 = (0.2126 * color2.r + 0.7152 * color2.g + 0.0722 * color2.b) + 0.05
|
||||||
|
|
||||||
|
if luminance1 > luminance2
|
||||||
|
luminance1 / luminance2
|
||||||
|
else
|
||||||
|
luminance2 / luminance1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Style/MethodParameterName
|
||||||
|
def rgb_to_hsl(r, g, b)
|
||||||
|
r /= 255.0
|
||||||
|
g /= 255.0
|
||||||
|
b /= 255.0
|
||||||
|
max = [r, g, b].max
|
||||||
|
min = [r, g, b].min
|
||||||
|
h = (max + min) / 2.0
|
||||||
|
s = (max + min) / 2.0
|
||||||
|
l = (max + min) / 2.0
|
||||||
|
|
||||||
|
if max == min
|
||||||
|
h = 0
|
||||||
|
s = 0 # achromatic
|
||||||
|
else
|
||||||
|
d = max - min
|
||||||
|
s = l >= 0.5 ? d / (2.0 - max - min) : d / (max + min)
|
||||||
|
|
||||||
|
case max
|
||||||
|
when r
|
||||||
|
h = (g - b) / d + (g < b ? 6.0 : 0)
|
||||||
|
when g
|
||||||
|
h = (b - r) / d + 2.0
|
||||||
|
when b
|
||||||
|
h = (r - g) / d + 4.0
|
||||||
|
end
|
||||||
|
|
||||||
|
h /= 6.0
|
||||||
|
end
|
||||||
|
|
||||||
|
[(h * 360).round, (s * 100).round, (l * 100).round]
|
||||||
|
end
|
||||||
|
|
||||||
|
def hue_to_rgb(p, q, t)
|
||||||
|
t += 1 if t.negative?
|
||||||
|
t -= 1 if t > 1
|
||||||
|
|
||||||
|
return (p + (q - p) * 6 * t) if t < 1 / 6.0
|
||||||
|
return q if t < 1 / 2.0
|
||||||
|
return (p + (q - p) * (2 / 3.0 - t) * 6) if t < 2 / 3.0
|
||||||
|
|
||||||
|
p
|
||||||
|
end
|
||||||
|
|
||||||
|
def hsl_to_rgb(h, s, l)
|
||||||
|
h /= 360.0
|
||||||
|
s /= 100.0
|
||||||
|
l /= 100.0
|
||||||
|
|
||||||
|
r = 0.0
|
||||||
|
g = 0.0
|
||||||
|
b = 0.0
|
||||||
|
|
||||||
|
if s == 0.0
|
||||||
|
r = l.to_f
|
||||||
|
g = l.to_f
|
||||||
|
b = l.to_f # achromatic
|
||||||
|
else
|
||||||
|
q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||||
|
p = 2 * l - q
|
||||||
|
r = hue_to_rgb(p, q, h + 1 / 3.0)
|
||||||
|
g = hue_to_rgb(p, q, h)
|
||||||
|
b = hue_to_rgb(p, q, h - 1 / 3.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
[(r * 255).round, (g * 255).round, (b * 255).round]
|
||||||
|
end
|
||||||
|
# rubocop:enable Style/MethodParameterName
|
||||||
|
|
||||||
|
def lighten_or_darken(color, by)
|
||||||
|
hue, saturation, light = rgb_to_hsl(color.r, color.g, color.b)
|
||||||
|
|
||||||
|
light = begin
|
||||||
|
if light < 50
|
||||||
|
[100, light + by].min
|
||||||
|
else
|
||||||
|
[0, light - by].max
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
|
||||||
|
end
|
||||||
|
|
||||||
|
def palette_from_histogram(result, quantity)
|
||||||
|
frequencies = result.scan(/([0-9]+)\:/).flatten.map(&:to_f)
|
||||||
|
hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
|
||||||
|
total_frequencies = frequencies.reduce(&:+).to_f
|
||||||
|
|
||||||
|
frequencies.map.with_index { |f, i| [f / total_frequencies, hex_values[i]] }
|
||||||
|
.sort_by { |r| -r[0] }
|
||||||
|
.reject { |r| r[1].size == 8 && r[1].end_with?('00') }
|
||||||
|
.map { |r| ColorDiff::Color::RGB.new(*r[1][0..5].scan(/../).map { |c| c.to_i(16) }) }
|
||||||
|
.slice(0, quantity)
|
||||||
|
end
|
||||||
|
|
||||||
|
def rgb_to_hex(rgb)
|
||||||
|
'#%02x%02x%02x' % [rgb.r, rgb.g, rgb.b]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -43,7 +43,7 @@ module Paperclip
|
||||||
|
|
||||||
begin
|
begin
|
||||||
cli.run
|
cli.run
|
||||||
rescue Cocaine::ExitStatusError
|
rescue Cocaine::ExitStatusError, ::Av::CommandError
|
||||||
dst.close(true)
|
dst.close(true)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Paperclip
|
||||||
|
module TranscoderExtensions
|
||||||
|
# Prevent the transcoder from modifying our meta hash
|
||||||
|
def initialize(file, options = {}, attachment = nil)
|
||||||
|
meta_value = attachment&.instance_read(:meta)
|
||||||
|
super
|
||||||
|
attachment&.instance_write(:meta, meta_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Paperclip::Transcoder.prepend(Paperclip::TranscoderExtensions)
|
Loading…
Reference in New Issue