1
0
mirror of https://github.com/funamitech/mastodon synced 2024-11-23 14:46:52 +09:00
This commit is contained in:
Noa Himesaka 2024-11-11 13:44:26 +09:00
commit 8f563dd680
613 changed files with 10756 additions and 4616 deletions

View File

@ -73,6 +73,16 @@ DB_PORT=5432
SECRET_KEY_BASE= SECRET_KEY_BASE=
OTP_SECRET= OTP_SECRET=
# Encryption secrets
# ------------------
# Must be available (and set to same values) for all server processes
# These are private/secret values, do not share outside hosting environment
# Use `bin/rails db:encryption:init` to generate fresh secrets
# Do not change these secrets once in use, as this would cause data loss and other issues
# ------------------
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# Web Push # Web Push
# -------- # --------

View File

@ -1,6 +1,7 @@
name: Bug Report (Web Interface) name: Bug Report (Web Interface)
description: If you are using Mastodon's web interface and something is not working as expected description: There is a problem using Mastodon's web interface.
labels: [bug, 'status/to triage', 'area/web interface'] labels: ['status/to triage', 'area/web interface']
type: Bug
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -47,8 +48,8 @@ body:
attributes: attributes:
label: Mastodon version label: Mastodon version
description: | description: |
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
placeholder: v4.1.2 placeholder: v4.3.0
validations: validations:
required: true required: true
- type: input - type: input
@ -56,7 +57,7 @@ body:
label: Browser name and version label: Browser name and version
description: | description: |
What browser are you using when getting this bug? Please specify the version as well. What browser are you using when getting this bug? Please specify the version as well.
placeholder: Firefox 105.0.3 placeholder: Firefox 131.0.0
validations: validations:
required: true required: true
- type: input - type: input
@ -64,7 +65,7 @@ body:
label: Operating system label: Operating system
description: | description: |
What OS are you running? Please specify the version as well. What OS are you running? Please specify the version as well.
placeholder: macOS 13.4.1 placeholder: macOS 15.0.1
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -1,7 +1,8 @@
name: Bug Report (server / API) name: Bug Report (server / API)
description: | description: |
If something is not working as expected, but is not from using the web interface. There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
labels: [bug, 'status/to triage'] labels: ['status/to triage']
type: 'Bug'
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -48,8 +49,8 @@ body:
attributes: attributes:
label: Mastodon version label: Mastodon version
description: | description: |
This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
placeholder: v4.1.2 placeholder: v4.3.0
validations: validations:
required: false required: false
- type: textarea - type: textarea
@ -59,7 +60,7 @@ body:
Any additional technical details you may have, like logs or error traces Any additional technical details you may have, like logs or error traces
value: | value: |
If this is happening on your own Mastodon server, please fill out those: If this is happening on your own Mastodon server, please fill out those:
- Ruby version: (from `ruby --version`, eg. v3.1.2) - Ruby version: (from `ruby --version`, eg. v3.3.5)
- Node.js version: (from `node --version`, eg. v18.16.0) - Node.js version: (from `node --version`, eg. v20.18.0)
validations: validations:
required: false required: false

View File

@ -0,0 +1,74 @@
name: Deployment troubleshooting
description: |
You are a server administrator and you are encountering a technical issue during installation, upgrade or operations of Mastodon.
labels: ['status/to triage']
type: 'Troubleshooting'
body:
- type: markdown
attributes:
value: |
Make sure that you are submitting a new bug that was not previously reported or already fixed.
Please use a concise and distinct title for the issue.
- type: textarea
attributes:
label: Steps to reproduce the problem
description: What were you trying to do?
value: |
1.
2.
3.
...
validations:
required: true
- type: input
attributes:
label: Expected behaviour
description: What should have happened?
validations:
required: true
- type: input
attributes:
label: Actual behaviour
description: What happened?
validations:
required: true
- type: textarea
attributes:
label: Detailed description
validations:
required: false
- type: input
attributes:
label: Mastodon instance
description: The address of the Mastodon instance where you experienced the issue
placeholder: mastodon.social
validations:
required: true
- type: input
attributes:
label: Mastodon version
description: |
This is displayed at the bottom of the About page, eg. `v4.4.0-alpha.1`
placeholder: v4.3.0
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
Details about your environment, like how Mastodon is deployed, if containers are used, version numbers, etc.
value: |
Please at least include those informations:
- Operating system: (eg. Ubuntu 22.04)
- Ruby version: (from `ruby --version`, eg. v3.3.5)
- Node.js version: (from `node --version`, eg. v20.18.0)
validations:
required: false
- type: textarea
attributes:
label: Technical details
description: |
Any additional technical details you may have, like logs or error traces
validations:
required: false

View File

@ -1,6 +1,6 @@
name: Feature Request name: Feature Request
description: I have a suggestion description: I have a suggestion
labels: [suggestion] type: Suggestion
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -32,6 +32,8 @@ jobs:
postgres: postgres:
- 14-alpine - 14-alpine
- 15-alpine - 15-alpine
- 16-alpine
- 17-alpine
services: services:
postgres: postgres:

View File

@ -124,7 +124,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
ruby-version: ruby-version:
- '3.1'
- '3.2' - '3.2'
- '.ruby-version' - '.ruby-version'
steps: steps:
@ -143,7 +142,7 @@ jobs:
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg libpam-dev additional-system-dependencies: ffmpeg imagemagick libpam-dev
- name: Load database schema - name: Load database schema
run: | run: |
@ -226,7 +225,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
ruby-version: ruby-version:
- '3.1'
- '3.2' - '3.2'
- '.ruby-version' - '.ruby-version'
steps: steps:
@ -245,7 +243,7 @@ jobs:
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg libpam-dev libyaml-dev additional-system-dependencies: ffmpeg libpam-dev
- name: Load database schema - name: Load database schema
run: './bin/rails db:create db:schema:load db:seed' run: './bin/rails db:create db:schema:load db:seed'
@ -305,7 +303,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
ruby-version: ruby-version:
- '3.1'
- '3.2' - '3.2'
- '.ruby-version' - '.ruby-version'
@ -325,7 +322,7 @@ jobs:
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg additional-system-dependencies: ffmpeg imagemagick
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript
@ -422,7 +419,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
ruby-version: ruby-version:
- '3.1'
- '3.2' - '3.2'
- '.ruby-version' - '.ruby-version'
search-image: search-image:
@ -445,7 +441,7 @@ jobs:
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg additional-system-dependencies: ffmpeg imagemagick
- name: Set up Javascript environment - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript uses: ./.github/actions/setup-javascript

2
.nvmrc
View File

@ -1 +1 @@
20.18 22.11

View File

@ -8,7 +8,7 @@ AllCops:
- lib/mastodon/migration_helpers.rb - lib/mastodon/migration_helpers.rb
ExtraDetails: true ExtraDetails: true
NewCops: enable NewCops: enable
TargetRubyVersion: 3.1 # Oldest supported ruby version TargetRubyVersion: 3.2 # Oldest supported ruby version
inherit_from: inherit_from:
- .rubocop/layout.yml - .rubocop/layout.yml

View File

@ -1 +1 @@
3.3.5 3.3.6

View File

@ -67,7 +67,7 @@ The following changelog entries focus on changes visible to users, administrator
```html ```html
<meta name="fediverse:creator" content="username@domain" /> <meta name="fediverse:creator" content="username@domain" />
``` ```
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors\ On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \
Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\ Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\
This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\ - **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\

View File

@ -12,10 +12,10 @@ ARG BUILDPLATFORM=${BUILDPLATFORM}
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
# renovate: datasource=docker depName=docker.io/ruby # renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.3.5" ARG RUBY_VERSION="3.3.6"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="20" ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm" ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim) # Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
@ -191,7 +191,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.15.3 ARG VIPS_VERSION=8.16.0
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

22
Gemfile
View File

@ -1,12 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '>= 3.1.0' ruby '>= 3.2.0'
gem 'propshaft' gem 'propshaft'
gem 'puma', '~> 6.3' gem 'puma', '~> 6.3'
gem 'rack', '~> 2.2.7' gem 'rack', '~> 2.2.7'
gem 'rails', '~> 7.1.1' gem 'rails', '~> 7.2.0'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
gem 'dotenv' gem 'dotenv'
@ -16,16 +16,16 @@ gem 'pghero'
gem 'aws-sdk-s3', '~> 1.123', require: false gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'fog-core', '<= 2.5.0' gem 'fog-core', '<= 2.6.0'
gem 'fog-openstack', '~> 1.0', require: false gem 'fog-openstack', '~> 1.0', require: false
gem 'jd-paperclip-azure', '~> 3.0', require: false
gem 'kt-paperclip', '~> 7.2' gem 'kt-paperclip', '~> 7.2'
gem 'md-paperclip-azure', '~> 2.2', require: false
gem 'ruby-vips', '~> 2.2', require: false gem 'ruby-vips', '~> 2.2', require: false
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8' gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.18.0', require: false gem 'bootsnap', '~> 1.18.0', require: false
gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543 gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3' gem 'chewy', '~> 7.3'
gem 'devise', '~> 4.9' gem 'devise', '~> 4.9'
@ -47,13 +47,14 @@ gem 'color_diff', '~> 0.1'
gem 'csv', '~> 3.2' gem 'csv', '~> 3.2'
gem 'discard', '~> 1.2' gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.6' gem 'doorkeeper', '~> 5.6'
gem 'faraday-httpclient'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'htmlentities', '~> 4.3' gem 'htmlentities', '~> 4.3'
gem 'http', '~> 5.2.0' gem 'http', '~> 5.2.0'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.7.0' gem 'httplog', '~> 1.7.0', require: false
gem 'i18n' gem 'i18n'
gem 'idn-ruby', require: 'idn' gem 'idn-ruby', require: 'idn'
gem 'inline_svg' gem 'inline_svg'
@ -61,7 +62,8 @@ gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'mime-types', '~> 3.6.0', require: 'mime/types/columnar'
gem 'mutex_m'
gem 'nokogiri', '~> 1.15' gem 'nokogiri', '~> 1.15'
gem 'oj', '~> 3.14' gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
@ -111,8 +113,8 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false gem 'opentelemetry-instrumentation-rails', '~> 0.32.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false
@ -220,7 +222,7 @@ gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1' gem 'xorcist', '~> 1.1'
gem 'net-http', '~> 0.4.0' gem 'net-http', '~> 0.5.0'
gem 'rubyzip', '~> 2.3' gem 'rubyzip', '~> 2.3'
gem 'hcaptcha', '~> 7.1' gem 'hcaptcha', '~> 7.1'

View File

@ -10,51 +10,46 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.4) actioncable (7.2.2)
actionpack (= 7.1.4) actionpack (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.4) actionmailbox (7.2.2)
actionpack (= 7.1.4) actionpack (= 7.2.2)
activejob (= 7.1.4) activejob (= 7.2.2)
activerecord (= 7.1.4) activerecord (= 7.2.2)
activestorage (= 7.1.4) activestorage (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
mail (>= 2.7.1) mail (>= 2.8.0)
net-imap actionmailer (7.2.2)
net-pop actionpack (= 7.2.2)
net-smtp actionview (= 7.2.2)
actionmailer (7.1.4) activejob (= 7.2.2)
actionpack (= 7.1.4) activesupport (= 7.2.2)
actionview (= 7.1.4) mail (>= 2.8.0)
activejob (= 7.1.4)
activesupport (= 7.1.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.4) actionpack (7.2.2)
actionview (= 7.1.4) actionview (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.4) useragent (~> 0.16)
actionpack (= 7.1.4) actiontext (7.2.2)
activerecord (= 7.1.4) actionpack (= 7.2.2)
activestorage (= 7.1.4) activerecord (= 7.2.2)
activesupport (= 7.1.4) activestorage (= 7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.4) actionview (7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
@ -64,31 +59,33 @@ GEM
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.1.4) activejob (7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.4) activemodel (7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
activerecord (7.1.4) activerecord (7.2.2)
activemodel (= 7.1.4) activemodel (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.4) activestorage (7.2.2)
actionpack (= 7.1.4) actionpack (= 7.2.2)
activejob (= 7.1.4) activejob (= 7.2.2)
activerecord (= 7.1.4) activerecord (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.4) activesupport (7.2.2)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
@ -100,32 +97,27 @@ GEM
attr_required (1.0.2) attr_required (1.0.2)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.983.0) aws-partitions (1.1001.0)
aws-sdk-core (3.209.1) aws-sdk-core (3.212.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.94.0) aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.167.0) aws-sdk-s3 (1.170.0)
aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.0) aws-sigv4 (1.10.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-blob (0.5.2)
azure-storage-common (~> 2.0) rexml
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.2.0) base64 (0.2.0)
bcp47_spec (0.2.1) bcp47_spec (0.2.1)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.3.0)
better_errors (2.10.1) better_errors (2.10.1)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
@ -137,9 +129,9 @@ GEM
blurhash (0.1.8) blurhash (0.1.8)
bootsnap (1.18.4) bootsnap (1.18.4)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (6.2.1) brakeman (6.2.2)
racc racc
browser (5.3.1) browser (6.0.0)
brpoplpush-redis_script (0.1.3) brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6) redis (>= 1.0, < 6)
@ -179,7 +171,7 @@ GEM
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.19.0) css_parser (1.19.1)
addressable addressable
csv (3.3.0) csv (3.3.0)
database_cleaner-active_record (2.2.0) database_cleaner-active_record (2.2.0)
@ -206,8 +198,8 @@ GEM
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
diff-lcs (1.5.1) diff-lcs (1.5.1)
discard (1.3.0) discard (1.4.0)
activerecord (>= 4.2, < 8) activerecord (>= 4.2, < 9.0)
docile (1.4.1) docile (1.4.1)
domain_name (0.6.20240107) domain_name (0.6.20240107)
doorkeeper (5.7.1) doorkeeper (5.7.1)
@ -231,38 +223,21 @@ GEM
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
excon (0.111.0) excon (0.112.0)
fabrication (2.31.0) fabrication (2.31.0)
faker (3.4.2) faker (3.5.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (2.12.0)
faraday-em_http (~> 1.0) faraday-net_http (>= 2.0, < 3.4)
faraday-em_synchrony (~> 1.0) json
faraday-excon (~> 1.1) logger
faraday-httpclient (~> 1.0) faraday-httpclient (2.0.1)
faraday-multipart (~> 1.0) httpclient (>= 2.2)
faraday-net_http (~> 1.0) faraday-net_http (3.3.0)
faraday-net_http_persistent (~> 1.0) net-http
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
fast_blank (1.0.1) fast_blank (1.0.1)
fastimage (2.3.1) fastimage (2.3.1)
ffi (1.16.3) ffi (1.17.0)
ffi-compiler (1.3.2) ffi-compiler (1.3.2)
ffi (>= 1.15.5) ffi (>= 1.15.5)
rake rake
@ -350,8 +325,12 @@ GEM
irb (1.14.1) irb (1.14.1)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jd-paperclip-azure (3.0.0)
addressable (~> 2.5)
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.2) json (2.7.4)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.15.3.1) json-jwt (1.15.3.1)
activesupport (>= 4.2) activesupport (>= 4.2)
@ -366,7 +345,7 @@ GEM
rack (>= 2.2, < 4) rack (>= 2.2, < 4)
rdf (~> 3.3) rdf (~> 3.3)
rexml (~> 3.2) rexml (~> 3.2)
json-ld-preloaded (3.3.0) json-ld-preloaded (3.3.1)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (5.0.1) json-schema (5.0.1)
@ -412,7 +391,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.22.0) loofah (2.23.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@ -424,26 +403,20 @@ GEM
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
matrix (0.4.2) matrix (0.4.2)
md-paperclip-azure (2.2.0)
addressable (~> 2.5)
azure-storage-blob (~> 2.0.1)
hashie (~> 5.0)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mime-types (3.5.2) mime-types (3.6.0)
logger
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2024.0820) mime-types-data (3.2024.1001)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.7) mini_portile2 (2.8.7)
minitest (5.25.1) minitest (5.25.1)
msgpack (1.7.2) msgpack (1.7.3)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.2.0) mutex_m (0.2.0)
net-http (0.4.1) net-http (0.5.0)
uri uri
net-http-persistent (4.0.2) net-imap (0.5.0)
connection_pool (~> 2.2)
net-imap (0.4.15)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -457,7 +430,7 @@ GEM
nokogiri (1.16.7) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.6) oj (3.16.7)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.2) omniauth (2.1.2)
@ -503,7 +476,7 @@ GEM
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-helpers-sql-obfuscation (0.2.0) opentelemetry-helpers-sql-obfuscation (0.2.0)
opentelemetry-common (~> 0.21) opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.1.0) opentelemetry-instrumentation-action_mailer (0.2.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
@ -515,13 +488,13 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1) opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_job (0.7.7) opentelemetry-instrumentation-active_job (0.7.8)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_model_serializers (0.20.2) opentelemetry-instrumentation-active_model_serializers (0.20.2)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_record (0.7.3) opentelemetry-instrumentation-active_record (0.8.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_support (0.6.0) opentelemetry-instrumentation-active_support (0.6.0)
@ -553,16 +526,16 @@ GEM
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (0.24.6) opentelemetry-instrumentation-rack (0.25.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rails (0.31.2) opentelemetry-instrumentation-rails (0.32.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.1.0) opentelemetry-instrumentation-action_mailer (~> 0.2.0)
opentelemetry-instrumentation-action_pack (~> 0.9.0) opentelemetry-instrumentation-action_pack (~> 0.9.0)
opentelemetry-instrumentation-action_view (~> 0.7.0) opentelemetry-instrumentation-action_view (~> 0.7.0)
opentelemetry-instrumentation-active_job (~> 0.7.0) opentelemetry-instrumentation-active_job (~> 0.7.0)
opentelemetry-instrumentation-active_record (~> 0.7.0) opentelemetry-instrumentation-active_record (~> 0.8.0)
opentelemetry-instrumentation-active_support (~> 0.6.0) opentelemetry-instrumentation-active_support (~> 0.6.0)
opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-redis (0.25.7) opentelemetry-instrumentation-redis (0.25.7)
@ -590,8 +563,8 @@ GEM
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.5.8) pg (1.5.9)
pghero (3.6.0) pghero (3.6.1)
activerecord (>= 6.1) activerecord (>= 6.1)
premailer (1.27.0) premailer (1.27.0)
addressable addressable
@ -638,20 +611,20 @@ GEM
rackup (1.0.0) rackup (1.0.0)
rack (< 3) rack (< 3)
webrick webrick
rails (7.1.4) rails (7.2.2)
actioncable (= 7.1.4) actioncable (= 7.2.2)
actionmailbox (= 7.1.4) actionmailbox (= 7.2.2)
actionmailer (= 7.1.4) actionmailer (= 7.2.2)
actionpack (= 7.1.4) actionpack (= 7.2.2)
actiontext (= 7.1.4) actiontext (= 7.2.2)
actionview (= 7.1.4) actionview (= 7.2.2)
activejob (= 7.1.4) activejob (= 7.2.2)
activemodel (= 7.1.4) activemodel (= 7.2.2)
activerecord (= 7.1.4) activerecord (= 7.2.2)
activestorage (= 7.1.4) activestorage (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.4) railties (= 7.2.2)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -663,13 +636,13 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
rails-i18n (7.0.9) rails-i18n (7.0.10)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
railties (7.1.4) railties (7.2.2)
actionpack (= 7.1.4) actionpack (= 7.2.2)
activesupport (= 7.1.4) activesupport (= 7.2.2)
irb irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
@ -698,9 +671,9 @@ GEM
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.3.8) rexml (3.3.9)
rotp (6.3.0) rotp (6.3.0)
rouge (4.3.0) rouge (4.4.0)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
@ -710,14 +683,14 @@ GEM
rspec-core (~> 3.13.0) rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0) rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.1) rspec-core (3.13.2)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-github (2.4.0) rspec-github (2.4.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-mocks (3.13.1) rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.0.1) rspec-rails (7.0.1)
@ -751,17 +724,17 @@ GEM
rubocop-performance (1.22.1) rubocop-performance (1.22.1)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.26.2) rubocop-rails (2.27.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0) rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.1.0) rubocop-rspec (3.2.0)
rubocop (~> 1.61) rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0) rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61) rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1) rubocop-rspec (~> 3, >= 3.0.1)
ruby-prof (1.7.0) ruby-prof (1.7.1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.17.0) ruby-saml (1.17.0)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
@ -769,7 +742,6 @@ GEM
ruby-vips (2.2.2) ruby-vips (2.2.2)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
rufus-scheduler (3.9.1) rufus-scheduler (3.9.1)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
@ -781,7 +753,8 @@ GEM
scenic (1.8.0) scenic (1.8.0)
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
selenium-webdriver (4.25.0) securerandom (0.3.1)
selenium-webdriver (4.26.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -822,7 +795,7 @@ GEM
stoplight (4.1.0) stoplight (4.1.0)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.1) stringio (3.1.1)
strong_migrations (2.0.0) strong_migrations (2.0.2)
activerecord (>= 6.1) activerecord (>= 6.1)
swd (1.3.0) swd (1.3.0)
activesupport (>= 3) activesupport (>= 3)
@ -864,6 +837,7 @@ GEM
unf_ext (0.0.9.1) unf_ext (0.0.9.1)
unicode-display_width (2.6.0) unicode-display_width (2.6.0)
uri (0.13.1) uri (0.13.1)
useragent (0.16.10)
validate_email (0.1.6) validate_email (0.1.6)
activemodel (>= 3.0) activemodel (>= 3.0)
mail (>= 2.2.5) mail (>= 2.2.5)
@ -902,7 +876,7 @@ GEM
xorcist (1.1.3) xorcist (1.1.3)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.18) zeitwerk (2.7.1)
PLATFORMS PLATFORMS
ruby ruby
@ -917,7 +891,7 @@ DEPENDENCIES
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.18.0) bootsnap (~> 1.18.0)
brakeman (~> 6.0) brakeman (~> 6.0)
browser (< 6) browser
bundler-audit (~> 0.9) bundler-audit (~> 0.9)
capybara (~> 3.39) capybara (~> 3.39)
charlock_holmes (~> 0.7.7) charlock_holmes (~> 0.7.7)
@ -939,10 +913,11 @@ DEPENDENCIES
email_spec email_spec
fabrication (~> 2.30) fabrication (~> 2.30)
faker (~> 3.2) faker (~> 3.2)
faraday-httpclient
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage fastimage
flatware-rspec flatware-rspec
fog-core (<= 2.5.0) fog-core (<= 2.6.0)
fog-openstack (~> 1.0) fog-openstack (~> 1.0)
haml-rails (~> 2.0) haml-rails (~> 2.0)
haml_lint haml_lint
@ -957,6 +932,7 @@ DEPENDENCIES
idn-ruby idn-ruby
inline_svg inline_svg
irb (~> 1.8) irb (~> 1.8)
jd-paperclip-azure (~> 3.0)
json-ld json-ld
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 5.0) json-schema (~> 5.0)
@ -968,10 +944,10 @@ DEPENDENCIES
lograge (~> 0.12) lograge (~> 0.12)
mail (~> 2.8) mail (~> 2.8)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
md-paperclip-azure (~> 2.2)
memory_profiler memory_profiler
mime-types (~> 3.5.0) mime-types (~> 3.6.0)
net-http (~> 0.4.0) mutex_m
net-http (~> 0.5.0)
net-ldap (~> 0.18) net-ldap (~> 0.18)
nokogiri (~> 1.15) nokogiri (~> 1.15)
oj (~> 3.14) oj (~> 3.14)
@ -991,8 +967,8 @@ DEPENDENCIES
opentelemetry-instrumentation-http_client (~> 0.22.3) opentelemetry-instrumentation-http_client (~> 0.22.3)
opentelemetry-instrumentation-net_http (~> 0.22.4) opentelemetry-instrumentation-net_http (~> 0.22.4)
opentelemetry-instrumentation-pg (~> 0.29.0) opentelemetry-instrumentation-pg (~> 0.29.0)
opentelemetry-instrumentation-rack (~> 0.24.1) opentelemetry-instrumentation-rack (~> 0.25.0)
opentelemetry-instrumentation-rails (~> 0.31.0) opentelemetry-instrumentation-rails (~> 0.32.0)
opentelemetry-instrumentation-redis (~> 0.25.3) opentelemetry-instrumentation-redis (~> 0.25.3)
opentelemetry-instrumentation-sidekiq (~> 0.25.2) opentelemetry-instrumentation-sidekiq (~> 0.25.2)
opentelemetry-sdk (~> 1.4) opentelemetry-sdk (~> 1.4)
@ -1009,7 +985,7 @@ DEPENDENCIES
rack-attack (~> 6.6) rack-attack (~> 6.6)
rack-cors (~> 2.0) rack-cors (~> 2.0)
rack-test (~> 2.1) rack-test (~> 2.1)
rails (~> 7.1.1) rails (~> 7.2.0)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 7.0) rails-i18n (~> 7.0)
rdf-normalize (~> 0.5) rdf-normalize (~> 0.5)
@ -1057,7 +1033,7 @@ DEPENDENCIES
xorcist (~> 1.1) xorcist (~> 1.1)
RUBY VERSION RUBY VERSION
ruby 3.3.4p94 ruby 3.3.5p100
BUNDLED WITH BUNDLED WITH
2.5.18 2.5.22

View File

@ -97,7 +97,7 @@ Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Stre
- **PostgreSQL** 12+ - **PostgreSQL** 12+
- **Redis** 4+ - **Redis** 4+
- **Ruby** 3.1+ - **Ruby** 3.2+
- **Node.js** 18+ - **Node.js** 18+
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation. The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.

View File

@ -3,6 +3,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake, # Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require File.expand_path('config/application', __dir__) require_relative 'config/application'
Rails.application.load_tasks Rails.application.load_tasks

View File

@ -41,11 +41,11 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
end end
end end
def outbox_url(**kwargs) def outbox_url(**)
if params[:account_username].present? if params[:account_username].present?
account_outbox_url(@account, **kwargs) account_outbox_url(@account, **)
else else
instance_actor_outbox_url(**kwargs) instance_actor_outbox_url(**)
end end
end end

View File

@ -5,7 +5,7 @@ module Admin
def index def index
authorize :email_domain_block, :index? authorize :email_domain_block, :index?
@email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page]) @email_domain_blocks = EmailDomainBlock.parents.includes(:children).order(id: :desc).page(params[:page])
@form = Form::EmailDomainBlockBatch.new @form = Form::EmailDomainBlockBatch.new
end end

View File

@ -32,7 +32,7 @@ module Admin
def deactivate_all def deactivate_all
authorize :invite, :deactivate_all? authorize :invite, :deactivate_all?
Invite.available.in_batches.update_all(expires_at: Time.now.utc) Invite.available.in_batches.touch_all(:expires_at)
redirect_to admin_invites_path redirect_to admin_invites_path
end end

View File

@ -4,7 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController
def index def index
authorize :preview_card, :review? authorize :preview_card, :review?
@locales = PreviewCardTrend.pluck('distinct language') @locales = PreviewCardTrend.locales
@preview_cards = filtered_preview_cards.page(params[:page]) @preview_cards = filtered_preview_cards.page(params[:page])
@form = Trends::PreviewCardBatch.new @form = Trends::PreviewCardBatch.new
end end

View File

@ -4,7 +4,7 @@ class Admin::Trends::StatusesController < Admin::BaseController
def index def index
authorize [:admin, :status], :review? authorize [:admin, :status], :review?
@locales = StatusTrend.pluck('distinct language') @locales = StatusTrend.locales
@statuses = filtered_statuses.page(params[:page]) @statuses = filtered_statuses.page(params[:page])
@form = Trends::StatusBatch.new @form = Trends::StatusBatch.new
end end

View File

@ -106,8 +106,8 @@ class Api::V1::AccountsController < Api::BaseController
render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id render json: { error: I18n.t('accounts.self_follow_error') }, status: 403 if current_user.account.id == @account.id
end end
def relationships(**options) def relationships(**)
AccountRelationshipsPresenter.new([@account], current_user.account_id, **options) AccountRelationshipsPresenter.new([@account], current_user.account_id, **)
end end
def account_ids def account_ids

View File

@ -17,6 +17,17 @@ class Api::V1::AnnualReportsController < Api::BaseController
relationships: @relationships relationships: @relationships
end end
def show
with_read_replica do
@presenter = AnnualReportsPresenter.new([@annual_report])
@relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
end
render json: @presenter,
serializer: REST::AnnualReportsSerializer,
relationships: @relationships
end
def read def read
@annual_report.view! @annual_report.view!
render_empty render_empty

View File

@ -28,8 +28,8 @@ class Api::V1::FollowRequestsController < Api::BaseController
@account ||= Account.find(params[:id]) @account ||= Account.find(params[:id])
end end
def relationships(**options) def relationships(**)
AccountRelationshipsPresenter.new([account], current_user.account_id, **options) AccountRelationshipsPresenter.new([account], current_user.account_id, **)
end end
def load_accounts def load_accounts

View File

@ -23,6 +23,6 @@ class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseControl
private private
def set_translation def set_translation
@translation = TranslateStatusService.new.call(@status, content_locale) @translation = TranslateStatusService.new.call(@status, I18n.locale.to_s)
end end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::Web::PushSubscriptionsController < Api::Web::BaseController class Api::Web::PushSubscriptionsController < Api::Web::BaseController
before_action :require_user! before_action :require_user!, except: :destroy
before_action :set_push_subscription, only: :update before_action :set_push_subscription, only: :update
before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions? before_action :destroy_previous_subscriptions, only: :create, if: :prior_subscriptions?
after_action :update_session_with_subscription, only: :create after_action :update_session_with_subscription, only: :create
@ -17,6 +17,13 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
end end
def destroy
push_subscription = ::Web::PushSubscription.find_by_token_for(:unsubscribe, params[:id])
push_subscription&.destroy
head 200
end
private private
def active_session def active_session

View File

@ -10,7 +10,7 @@ module Auth::CaptchaConcern
end end
def captcha_available? def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present?
end end
def captcha_enabled? def captcha_enabled?

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Oauth::UserinfoController < Api::BaseController
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
before_action :require_user!
def show
@account = current_account
render json: @account, serializer: OauthUserinfoSerializer
end
end

View File

@ -2,7 +2,7 @@
module Admin::SettingsHelper module Admin::SettingsHelper
def captcha_available? def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present? Rails.configuration.x.captcha.secret_key.present? && Rails.configuration.x.captcha.site_key.present?
end end
def login_activity_title(activity) def login_activity_title(activity)

View File

@ -120,18 +120,6 @@ module ApplicationHelper
inline_svg_tag 'check.svg' inline_svg_tag 'check.svg'
end end
def visibility_icon(status)
if status.public_visibility?
material_symbol('globe', title: I18n.t('statuses.visibilities.public'))
elsif status.unlisted_visibility?
material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted'))
elsif status.private_visibility? || status.limited_visibility?
material_symbol('lock', title: I18n.t('statuses.visibilities.private'))
elsif status.direct_visibility?
material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct'))
end
end
def interrelationships_icon(relationships, account_id) def interrelationships_icon(relationships, account_id)
if relationships.following[account_id] && relationships.followed_by[account_id] if relationships.following[account_id] && relationships.followed_by[account_id]
material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive')
@ -245,18 +233,23 @@ module ApplicationHelper
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options) tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
end end
def recent_tag_usage(tag)
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
end
# glitch-soc addition to handle the multiple flavors # glitch-soc addition to handle the multiple flavors
def preload_locale_pack def preload_locale_pack
supported_locales = Themes.instance.flavour(current_flavour)['locales'] supported_locales = Themes.instance.flavour(current_flavour)['locales']
preload_pack_asset "locales/#{current_flavour}/#{I18n.locale}-json.js" if supported_locales.include?(I18n.locale.to_s) preload_pack_asset "locales/#{current_flavour}/#{I18n.locale}-json.js" if supported_locales.include?(I18n.locale.to_s)
end end
def flavoured_javascript_pack_tag(pack_name, **options) def flavoured_javascript_pack_tag(pack_name, **)
javascript_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) javascript_pack_tag("flavours/#{current_flavour}/#{pack_name}", **)
end end
def flavoured_stylesheet_pack_tag(pack_name, **options) def flavoured_stylesheet_pack_tag(pack_name, **)
stylesheet_pack_tag("flavours/#{current_flavour}/#{pack_name}", **options) stylesheet_pack_tag("flavours/#{current_flavour}/#{pack_name}", **)
end end
def preload_signed_in_js_packs def preload_signed_in_js_packs

View File

@ -1,6 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
module FormattingHelper module FormattingHelper
SYNDICATED_EMOJI_STYLES = <<~CSS.squish
height: 1.1em;
margin: -.2ex .15em .2ex;
object-fit: contain;
vertical-align: middle;
width: 1.1em;
CSS
def html_aware_format(text, local, options = {}) def html_aware_format(text, local, options = {})
HtmlAwareFormatter.new(text, local, options).to_s HtmlAwareFormatter.new(text, local, options).to_s
end end
@ -19,42 +27,33 @@ module FormattingHelper
module_function :extract_status_plain_text module_function :extract_status_plain_text
def status_content_format(status) def status_content_format(status)
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type) MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span|
span.add_attributes(
'app.formatter.content.type' => 'status',
'app.formatter.content.origin' => status.local? ? 'local' : 'remote'
)
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
end
end end
def rss_status_content_format(status) def rss_status_content_format(status)
html = status_content_format(status)
before_html = if status.spoiler_text?
tag.p do
tag.strong do
I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)
end
status.spoiler_text
end + tag.hr
end
after_html = if status.preloadable_poll
tag.p do
safe_join(
status.preloadable_poll.options.map do |o|
tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true)
end,
tag.br
)
end
end
prerender_custom_emojis( prerender_custom_emojis(
safe_join([before_html, html, after_html]), wrapped_status_content_format(status),
status.emojis, status.emojis,
style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex' style: SYNDICATED_EMOJI_STYLES
).to_str ).to_str
end end
def account_bio_format(account) def account_bio_format(account)
html_aware_format(account.note, account.local?) MastodonOTELTracer.in_span('HtmlAwareFormatter rendering') do |span|
span.add_attributes(
'app.formatter.content.type' => 'account_bio',
'app.formatter.content.origin' => account.local? ? 'local' : 'remote'
)
html_aware_format(account.note, account.local?)
end
end end
def account_field_value_format(field, with_rel_me: true) def account_field_value_format(field, with_rel_me: true)
@ -64,4 +63,47 @@ module FormattingHelper
html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false) html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
end end
end end
private
def wrapped_status_content_format(status)
safe_join [
rss_content_preroll(status),
status_content_format(status),
rss_content_postroll(status),
]
end
def rss_content_preroll(status)
if status.spoiler_text?
safe_join [
tag.p { spoiler_with_warning(status) },
tag.hr,
]
end
end
def spoiler_with_warning(status)
safe_join [
tag.strong { I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale) },
status.spoiler_text,
]
end
def rss_content_postroll(status)
if status.preloadable_poll
tag.p do
poll_option_tags(status)
end
end
end
def poll_option_tags(status)
safe_join(
status.preloadable_poll.options.map do |option|
tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', option, disabled: true)
end,
tag.br
)
end
end end

View File

@ -162,7 +162,7 @@ module LanguagesHelper
th: ['Thai', 'ไทย'].freeze, th: ['Thai', 'ไทย'].freeze,
ti: ['Tigrinya', 'ትግርኛ'].freeze, ti: ['Tigrinya', 'ትግርኛ'].freeze,
tk: ['Turkmen', 'Türkmen'].freeze, tk: ['Turkmen', 'Türkmen'].freeze,
tl: ['Tagalog', 'Wikang Tagalog'].freeze, tl: ['Tagalog', 'Tagalog'].freeze,
tn: ['Tswana', 'Setswana'].freeze, tn: ['Tswana', 'Setswana'].freeze,
to: ['Tonga', 'faka Tonga'].freeze, to: ['Tonga', 'faka Tonga'].freeze,
tr: ['Turkish', 'Türkçe'].freeze, tr: ['Turkish', 'Türkçe'].freeze,

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module MediaComponentHelper module MediaComponentHelper
def render_video_component(status, **options) def render_video_component(status, **)
video = status.ordered_media_attachments.first video = status.ordered_media_attachments.first
meta = video.file.meta || {} meta = video.file.meta || {}
@ -18,14 +18,14 @@ module MediaComponentHelper
media: [ media: [
serialize_media_attachment(video), serialize_media_attachment(video),
].as_json, ].as_json,
}.merge(**options) }.merge(**)
react_component :video, component_params do react_component :video, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end end
end end
def render_audio_component(status, **options) def render_audio_component(status, **)
audio = status.ordered_media_attachments.first audio = status.ordered_media_attachments.first
meta = audio.file.meta || {} meta = audio.file.meta || {}
@ -38,19 +38,19 @@ module MediaComponentHelper
foregroundColor: meta.dig('colors', 'foreground'), foregroundColor: meta.dig('colors', 'foreground'),
accentColor: meta.dig('colors', 'accent'), accentColor: meta.dig('colors', 'accent'),
duration: meta.dig('original', 'duration'), duration: meta.dig('original', 'duration'),
}.merge(**options) }.merge(**)
react_component :audio, component_params do react_component :audio, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end end
end end
def render_media_gallery_component(status, **options) def render_media_gallery_component(status, **)
component_params = { component_params = {
sensitive: sensitive_viewer?(status, current_account), sensitive: sensitive_viewer?(status, current_account),
autoplay: prefers_autoplay?, autoplay: prefers_autoplay?,
media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json }, media: status.ordered_media_attachments.map { |a| serialize_media_attachment(a).as_json },
}.merge(**options) }.merge(**)
react_component :media_gallery, component_params do react_component :media_gallery, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments } render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }

View File

@ -16,6 +16,6 @@ module RegistrationHelper
end end
def ip_blocked?(remote_ip) def ip_blocked?(remote_ip)
IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists?
end end
end end

View File

@ -14,8 +14,8 @@ module RoutingHelper
end end
end end
def full_asset_url(source, **options) def full_asset_url(source, **)
source = ActionController::Base.helpers.asset_url(source, **options) unless use_storage? source = ActionController::Base.helpers.asset_url(source, **) unless use_storage?
URI.join(asset_host, source).to_s URI.join(asset_host, source).to_s
end end
@ -24,12 +24,12 @@ module RoutingHelper
Rails.configuration.action_controller.asset_host || root_url Rails.configuration.action_controller.asset_host || root_url
end end
def frontend_asset_path(source, **options) def frontend_asset_path(source, **)
asset_pack_path("media/#{source}", **options) asset_pack_path("media/#{source}", **)
end end
def frontend_asset_url(source, **options) def frontend_asset_url(source, **)
full_asset_url(frontend_asset_path(source, **options)) full_asset_url(frontend_asset_path(source, **))
end end
def use_storage? def use_storage?

View File

@ -12,7 +12,7 @@ module StatusesHelper
}.freeze }.freeze
def nothing_here(extra_classes = '') def nothing_here(extra_classes = '')
content_tag(:div, class: "nothing-here #{extra_classes}") do tag.div(class: ['nothing-here', extra_classes]) do
t('accounts.nothing_here') t('accounts.nothing_here')
end end
end end

View File

@ -327,31 +327,24 @@ Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
if (!input) return; if (!input) return;
const oldReadOnly = input.readOnly; navigator.clipboard
.writeText(input.value)
input.readOnly = false; .then(() => {
input.focus();
input.select();
input.setSelectionRange(0, input.value.length);
try {
if (document.execCommand('copy')) {
input.blur();
const parent = target.parentElement; const parent = target.parentElement;
if (!parent) return; if (parent) {
parent.classList.add('copied'); parent.classList.add('copied');
setTimeout(() => { setTimeout(() => {
parent.classList.remove('copied'); parent.classList.remove('copied');
}, 700); }, 700);
} }
} catch (err) {
console.error(err);
}
input.readOnly = oldReadOnly; return true;
})
.catch((error: unknown) => {
console.error(error);
});
}); });
const toggleSidebar = () => { const toggleSidebar = () => {

View File

@ -8,6 +8,7 @@ import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import type { import type {
ApiNotificationGroupJSON, ApiNotificationGroupJSON,
ApiNotificationJSON, ApiNotificationJSON,
NotificationType,
} from 'flavours/glitch/api_types/notifications'; } from 'flavours/glitch/api_types/notifications';
import { allNotificationTypes } from 'flavours/glitch/api_types/notifications'; import { allNotificationTypes } from 'flavours/glitch/api_types/notifications';
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
@ -15,6 +16,7 @@ import { usePendingItems } from 'flavours/glitch/initial_state';
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
import { import {
selectSettingsNotificationsExcludedTypes, selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsGroupFollows,
selectSettingsNotificationsQuickFilterActive, selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsShows, selectSettingsNotificationsShows,
} from 'flavours/glitch/selectors/settings'; } from 'flavours/glitch/selectors/settings';
@ -68,17 +70,19 @@ function dispatchAssociatedRecords(
dispatch(importFetchedStatuses(fetchedStatuses)); dispatch(importFetchedStatuses(fetchedStatuses));
} }
const supportedGroupedNotificationTypes = ['favourite', 'reblog']; function selectNotificationGroupedTypes(state: RootState) {
const types: NotificationType[] = ['favourite', 'reblog'];
export function shouldGroupNotificationType(type: string) { if (selectSettingsNotificationsGroupFollows(state)) types.push('follow');
return supportedGroupedNotificationTypes.includes(type);
return types;
} }
export const fetchNotifications = createDataLoadingThunk( export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch', 'notificationGroups/fetch',
async (_params, { getState }) => async (_params, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes, grouped_types: selectNotificationGroupedTypes(getState()),
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
({ notifications, accounts, statuses }, { dispatch }) => { ({ notifications, accounts, statuses }, { dispatch }) => {
@ -102,7 +106,7 @@ export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap', 'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }, { getState }) => async (params: { gap: NotificationGap }, { getState }) =>
apiFetchNotificationGroups({ apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes, grouped_types: selectNotificationGroupedTypes(getState()),
max_id: params.gap.maxId, max_id: params.gap.maxId,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
}), }),
@ -119,7 +123,7 @@ export const pollRecentNotifications = createDataLoadingThunk(
'notificationGroups/pollRecentNotifications', 'notificationGroups/pollRecentNotifications',
async (_params, { getState }) => { async (_params, { getState }) => {
return apiFetchNotificationGroups({ return apiFetchNotificationGroups({
grouped_types: supportedGroupedNotificationTypes, grouped_types: selectNotificationGroupedTypes(getState()),
max_id: undefined, max_id: undefined,
exclude_types: getExcludedTypes(getState()), exclude_types: getExcludedTypes(getState()),
// In slow mode, we don't want to include notifications that duplicate the already-displayed ones // In slow mode, we don't want to include notifications that duplicate the already-displayed ones
@ -168,7 +172,10 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
dispatchAssociatedRecords(dispatch, [notification]); dispatchAssociatedRecords(dispatch, [notification]);
return notification; return {
notification,
groupedTypes: selectNotificationGroupedTypes(state),
};
}, },
); );

View File

@ -21,6 +21,7 @@ export const allNotificationTypes = [
'admin.report', 'admin.report',
'moderation_warning', 'moderation_warning',
'severed_relationships', 'severed_relationships',
'annual_report',
]; ];
export type NotificationWithStatusType = export type NotificationWithStatusType =
@ -39,7 +40,8 @@ export type NotificationType =
| 'moderation_warning' | 'moderation_warning'
| 'severed_relationships' | 'severed_relationships'
| 'admin.sign_up' | 'admin.sign_up'
| 'admin.report'; | 'admin.report'
| 'annual_report';
export interface BaseNotificationJSON { export interface BaseNotificationJSON {
id: string; id: string;
@ -132,6 +134,15 @@ interface AccountRelationshipSeveranceNotificationJSON
event: ApiAccountRelationshipSeveranceEventJSON; event: ApiAccountRelationshipSeveranceEventJSON;
} }
export interface ApiAnnualReportEventJSON {
year: string;
}
interface AnnualReportNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'annual_report';
annual_report: ApiAnnualReportEventJSON;
}
export type ApiNotificationJSON = export type ApiNotificationJSON =
| SimpleNotificationJSON | SimpleNotificationJSON
| ReportNotificationJSON | ReportNotificationJSON
@ -144,7 +155,8 @@ export type ApiNotificationGroupJSON =
| ReportNotificationGroupJSON | ReportNotificationGroupJSON
| AccountRelationshipSeveranceNotificationGroupJSON | AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON | NotificationGroupWithStatusJSON
| ModerationWarningNotificationGroupJSON; | ModerationWarningNotificationGroupJSON
| AnnualReportNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON { export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[]; accounts: ApiAccountJSON[];

View File

@ -1,4 +1,4 @@
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren, JSX } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -1,6 +1,4 @@
/* Significantly rewritten from upstream to keep the old design for now */ import { StatusBanner, BannerVariant } from './status_banner';
import { FormattedMessage } from 'react-intl';
export const ContentWarning: React.FC<{ export const ContentWarning: React.FC<{
text: string; text: string;
@ -8,20 +6,12 @@ export const ContentWarning: React.FC<{
onClick?: () => void; onClick?: () => void;
icons?: React.ReactNode[]; icons?: React.ReactNode[];
}> = ({ text, expanded, onClick, icons }) => ( }> = ({ text, expanded, onClick, icons }) => (
<p> <StatusBanner
<span dangerouslySetInnerHTML={{ __html: text }} className='translate' />{' '} expanded={expanded}
<button onClick={onClick}
type='button' variant={BannerVariant.Warning}
className='status__content__spoiler-link' >
onClick={onClick} {icons}
aria-expanded={expanded} <p dangerouslySetInnerHTML={{ __html: text }} />
> </StatusBanner>
{expanded ? (
<FormattedMessage id='status.show_less' defaultMessage='Show less' />
) : (
<FormattedMessage id='status.show_more' defaultMessage='Show more' />
)}
{icons}
</button>
</p>
); );

View File

@ -10,13 +10,16 @@ export const FilterWarning: React.FC<{
<StatusBanner <StatusBanner
expanded={expanded} expanded={expanded}
onClick={onClick} onClick={onClick}
variant={BannerVariant.Blue} variant={BannerVariant.Filter}
> >
<p> <p>
<FormattedMessage <FormattedMessage
id='filter_warning.matches_filter' id='filter_warning.matches_filter'
defaultMessage='Matches filter “{title}”' defaultMessage='Matches filter “<span>{title}</span>”'
values={{ title }} values={{
title,
span: (chunks) => <span className='filter-name'>{chunks}</span>,
}}
/> />
</p> </p>
</StatusBanner> </StatusBanner>

View File

@ -98,12 +98,12 @@ class Item extends PureComponent {
height = 50; height = 50;
} }
if (attachment.get('description')?.length > 0) {
badges.push(<AltTextBadge key='alt' description={attachment.get('description')} />);
}
const description = attachment.getIn(['translation', 'description']) || attachment.get('description'); const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
if (description?.length > 0) {
badges.push(<AltTextBadge key='alt' description={description} />);
}
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
return ( return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>

View File

@ -13,11 +13,14 @@ class ModalRoot extends PureComponent {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
backgroundColor: PropTypes.shape({ backgroundColor: PropTypes.oneOfType([
r: PropTypes.number, PropTypes.string,
g: PropTypes.number, PropTypes.shape({
b: PropTypes.number, r: PropTypes.number,
}), g: PropTypes.number,
b: PropTypes.number,
}),
]),
noEsc: PropTypes.bool, noEsc: PropTypes.bool,
ignoreFocus: PropTypes.bool, ignoreFocus: PropTypes.bool,
...WithOptionalRouterPropTypes, ...WithOptionalRouterPropTypes,
@ -146,14 +149,17 @@ class ModalRoot extends PureComponent {
let backgroundColor = null; let backgroundColor = null;
if (this.props.backgroundColor) { if (this.props.backgroundColor && typeof this.props.backgroundColor === 'string') {
backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); backgroundColor = this.props.backgroundColor;
} else if (this.props.backgroundColor) {
const darkenedColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
backgroundColor = `rgb(${darkenedColor.r}, ${darkenedColor.g}, ${darkenedColor.b})`;
} }
return ( return (
<div className='modal-root' ref={this.setRef}> <div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} /> <div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor }} />
<div role='dialog' className='modal-root__container'>{children}</div> <div role='dialog' className='modal-root__container'>{children}</div>
</div> </div>
</div> </div>

View File

@ -41,12 +41,14 @@ const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
class Poll extends ImmutablePureComponent { class Poll extends ImmutablePureComponent {
static propTypes = { static propTypes = {
identity: identityContextPropShape, identity: identityContextPropShape,
poll: ImmutablePropTypes.map, poll: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map.isRequired,
lang: PropTypes.string, lang: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
refresh: PropTypes.func, refresh: PropTypes.func,
onVote: PropTypes.func, onVote: PropTypes.func,
onInteractionModal: PropTypes.func,
}; };
state = { state = {
@ -117,7 +119,11 @@ class Poll extends ImmutablePureComponent {
return; return;
} }
this.props.onVote(Object.keys(this.state.selected)); if (this.props.identity.signedIn) {
this.props.onVote(Object.keys(this.state.selected));
} else {
this.props.onInteractionModal('vote', this.props.status);
}
}; };
handleRefresh = () => { handleRefresh = () => {
@ -232,7 +238,7 @@ class Poll extends ImmutablePureComponent {
</ul> </ul>
<div className='poll__footer'> <div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled || !this.props.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>} {!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>} {showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
{votesCount} {votesCount}

View File

@ -1,4 +1,5 @@
import { memo } from 'react'; import { memo } from 'react';
import type { JSX } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl'; import { FormattedMessage, FormattedNumber } from 'react-intl';

View File

@ -590,12 +590,15 @@ class Status extends ImmutablePureComponent {
let prepend, rebloggedByText; let prepend, rebloggedByText;
const matchedFilters = status.get('matched_filters');
if (hidden) { if (hidden) {
return ( return (
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}> <HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
<div ref={this.handleRef} className='status focusable' tabIndex={unfocusable ? null : 0}> <div ref={this.handleRef} className='status focusable' tabIndex={unfocusable ? null : 0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span> <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span> {status.get('spoiler_text').length > 0 && (<span>{status.get('spoiler_text')}</span>)}
{isExpanded && <span>{status.get('content')}</span>}
</div> </div>
</HotKeys> </HotKeys>
); );
@ -604,7 +607,6 @@ class Status extends ImmutablePureComponent {
const connectUp = previousId && previousId === status.get('in_reply_to_id'); const connectUp = previousId && previousId === status.get('in_reply_to_id');
const connectToRoot = rootId && rootId === status.get('in_reply_to_id'); const connectToRoot = rootId && rootId === status.get('in_reply_to_id');
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id'); const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
const matchedFilters = status.get('matched_filters');
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = this.props.muted ? {} : { const minHandlers = this.props.muted ? {} : {
@ -655,7 +657,7 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/>, />,
); );
} else if (['image', 'gifv'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) { } else if (['image', 'gifv', 'unknown'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
media.push( media.push(
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => ( {Component => (
@ -749,7 +751,7 @@ class Status extends ImmutablePureComponent {
if (status.get('poll')) { if (status.get('poll')) {
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
contentMedia.push(<PollContainer pollId={status.get('poll')} lang={language} />); contentMedia.push(<PollContainer pollId={status.get('poll')} status={status} lang={language} />);
contentMediaIcons.push('tasks'); contentMediaIcons.push('tasks');
} }
@ -814,7 +816,8 @@ class Status extends ImmutablePureComponent {
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />} {(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{(!muted || !isCollapsed) && ( {(!muted || !isCollapsed) && (
<header className='status__info'> /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<header onClick={this.parseClick} className='status__info'>
<StatusHeader <StatusHeader
status={status} status={status}
friend={account} friend={account}

View File

@ -243,7 +243,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick }); menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick });
} }
if (publicStatus && (signedIn || !isRemote)) { if (publicStatus && !isRemote) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }

View File

@ -1,8 +1,8 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export enum BannerVariant { export enum BannerVariant {
Yellow = 'yellow', Warning = 'warning',
Blue = 'blue', Filter = 'filter',
} }
export const StatusBanner: React.FC<{ export const StatusBanner: React.FC<{
@ -11,9 +11,9 @@ export const StatusBanner: React.FC<{
expanded?: boolean; expanded?: boolean;
onClick?: () => void; onClick?: () => void;
}> = ({ children, variant, expanded, onClick }) => ( }> = ({ children, variant, expanded, onClick }) => (
<div <label
className={ className={
variant === BannerVariant.Yellow variant === BannerVariant.Warning
? 'content-warning' ? 'content-warning'
: 'content-warning content-warning--filter' : 'content-warning content-warning--filter'
} }
@ -26,6 +26,11 @@ export const StatusBanner: React.FC<{
id='content_warning.hide' id='content_warning.hide'
defaultMessage='Hide post' defaultMessage='Hide post'
/> />
) : variant === BannerVariant.Warning ? (
<FormattedMessage
id='content_warning.show_more'
defaultMessage='Show more'
/>
) : ( ) : (
<FormattedMessage <FormattedMessage
id='content_warning.show' id='content_warning.show'
@ -33,5 +38,5 @@ export const StatusBanner: React.FC<{
/> />
)} )}
</button> </button>
</div> </label>
); );

View File

@ -378,7 +378,7 @@ class StatusContent extends PureComponent {
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
let spoilerIcons = []; let spoilerIcons = [];
if (hidden && mediaIcons) { if (mediaIcons) {
const mediaComponents = { const mediaComponents = {
'link': LinkIcon, 'link': LinkIcon,
'picture-o': ImageIcon, 'picture-o': ImageIcon,

View File

@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { openModal } from 'flavours/glitch/actions/modal';
import { fetchPoll, vote } from 'flavours/glitch/actions/polls'; import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
import Poll from 'flavours/glitch/components/poll'; import Poll from 'flavours/glitch/components/poll';
@ -17,6 +18,17 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({
onVote (choices) { onVote (choices) {
dispatch(vote(pollId, choices)); dispatch(vote(pollId, choices));
}, },
onInteractionModal (type, status) {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
}
}); });
const mapStateToProps = (state, { pollId }) => ({ const mapStateToProps = (state, { pollId }) => ({

View File

@ -327,31 +327,24 @@ Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
if (!input) return; if (!input) return;
const oldReadOnly = input.readOnly; navigator.clipboard
.writeText(input.value)
input.readOnly = false; .then(() => {
input.focus();
input.select();
input.setSelectionRange(0, input.value.length);
try {
if (document.execCommand('copy')) {
input.blur();
const parent = target.parentElement; const parent = target.parentElement;
if (!parent) return; if (parent) {
parent.classList.add('copied'); parent.classList.add('copied');
setTimeout(() => { setTimeout(() => {
parent.classList.remove('copied'); parent.classList.remove('copied');
}, 700); }, 700);
} }
} catch (err) {
console.error(err);
}
input.readOnly = oldReadOnly; return true;
})
.catch((error: unknown) => {
console.error(error);
});
}); });
const toggleSidebar = () => { const toggleSidebar = () => {

View File

@ -0,0 +1,69 @@
import { FormattedMessage } from 'react-intl';
import booster from '@/images/archetypes/booster.png';
import lurker from '@/images/archetypes/lurker.png';
import oracle from '@/images/archetypes/oracle.png';
import pollster from '@/images/archetypes/pollster.png';
import replier from '@/images/archetypes/replier.png';
import type { Archetype as ArchetypeData } from 'flavours/glitch/models/annual_report';
export const Archetype: React.FC<{
data: ArchetypeData;
}> = ({ data }) => {
let illustration, label;
switch (data) {
case 'booster':
illustration = booster;
label = (
<FormattedMessage
id='annual_report.summary.archetype.booster'
defaultMessage='The cool-hunter'
/>
);
break;
case 'replier':
illustration = replier;
label = (
<FormattedMessage
id='annual_report.summary.archetype.replier'
defaultMessage='The social butterfly'
/>
);
break;
case 'pollster':
illustration = pollster;
label = (
<FormattedMessage
id='annual_report.summary.archetype.pollster'
defaultMessage='The pollster'
/>
);
break;
case 'lurker':
illustration = lurker;
label = (
<FormattedMessage
id='annual_report.summary.archetype.lurker'
defaultMessage='The lurker'
/>
);
break;
case 'oracle':
illustration = oracle;
label = (
<FormattedMessage
id='annual_report.summary.archetype.oracle'
defaultMessage='The oracle'
/>
);
break;
}
return (
<div className='annual-report__bento__box annual-report__summary__archetype'>
<div className='annual-report__summary__archetype__label'>{label}</div>
<img src={illustration} alt='' />
</div>
);
};

View File

@ -0,0 +1,69 @@
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
export const Followers: React.FC<{
data: TimeSeriesMonth[];
total?: number;
}> = ({ data, total }) => {
const change = data.reduce((sum, item) => sum + item.followers, 0);
const cumulativeGraph = data.reduce(
(newData, item) => [
...newData,
item.followers + (newData[newData.length - 1] ?? 0),
],
[0],
);
return (
<div className='annual-report__bento__box annual-report__summary__followers'>
<Sparklines data={cumulativeGraph} margin={0}>
<svg>
<defs>
<linearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop
offset='0%'
stopColor='var(--sparkline-gradient-top)'
stopOpacity='1'
/>
<stop
offset='100%'
stopColor='var(--sparkline-gradient-bottom)'
stopOpacity='0'
/>
</linearGradient>
</defs>
</svg>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
<div className='annual-report__summary__followers__foreground'>
<div className='annual-report__summary__followers__number'>
{change > -1 ? '+' : '-'}
<FormattedNumber value={change} />
</div>
<div className='annual-report__summary__followers__label'>
<span>
<FormattedMessage
id='annual_report.summary.followers.followers'
defaultMessage='followers'
/>
</span>
<div className='annual-report__summary__followers__footnote'>
<FormattedMessage
id='annual_report.summary.followers.total'
defaultMessage='{count} total'
values={{ count: <ShortNumber value={total ?? 0} /> }}
/>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-assignment */
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
import { me } from 'flavours/glitch/initial_state';
import type { TopStatuses } from 'flavours/glitch/models/annual_report';
import {
makeGetStatus,
makeGetPictureInPicture,
} from 'flavours/glitch/selectors';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const getStatus = makeGetStatus() as unknown as (arg0: any, arg1: any) => any;
const getPictureInPicture = makeGetPictureInPicture() as unknown as (
arg0: any,
arg1: any,
) => any;
export const HighlightedPost: React.FC<{
data: TopStatuses;
}> = ({ data }) => {
let statusId, label;
if (data.by_reblogs) {
statusId = data.by_reblogs;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_reblogs'
defaultMessage='most boosted post'
/>
);
} else if (data.by_favourites) {
statusId = data.by_favourites;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_favourites'
defaultMessage='most favourited post'
/>
);
} else {
statusId = data.by_replies;
label = (
<FormattedMessage
id='annual_report.summary.highlighted_post.by_replies'
defaultMessage='post with the most replies'
/>
);
}
const dispatch = useAppDispatch();
const domain = useAppSelector((state) => state.meta.get('domain'));
const status = useAppSelector((state) =>
statusId ? getStatus(state, { id: statusId }) : undefined,
);
const pictureInPicture = useAppSelector((state) =>
statusId ? getPictureInPicture(state, { id: statusId }) : undefined,
);
const account = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const handleToggleHidden = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
if (!status) {
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post' />
);
}
const displayName = (
<span className='display-name'>
<strong className='display-name__html'>
<FormattedMessage
id='annual_report.summary.highlighted_post.possessive'
defaultMessage="{name}'s"
values={{
name: account && (
<bdi
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
),
}}
/>
</strong>
<span className='display-name__account'>{label}</span>
</span>
);
return (
<div className='annual-report__bento__box annual-report__summary__most-boosted-post'>
<DetailedStatus
status={status}
pictureInPicture={pictureInPicture}
domain={domain}
onToggleHidden={handleToggleHidden}
overrideDisplayName={displayName}
expanded={false}
/>
</div>
);
};

View File

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import {
importFetchedStatuses,
importFetchedAccounts,
} from 'flavours/glitch/actions/importer';
import { apiRequestGet, apiRequestPost } from 'flavours/glitch/api';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
import type { AnnualReport as AnnualReportData } from 'flavours/glitch/models/annual_report';
import type { Status } from 'flavours/glitch/models/status';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { Archetype } from './archetype';
import { Followers } from './followers';
import { HighlightedPost } from './highlighted_post';
import { MostUsedHashtag } from './most_used_hashtag';
import { NewPosts } from './new_posts';
import { Percentile } from './percentile';
interface AnnualReportResponse {
annual_reports: AnnualReportData[];
accounts: Account[];
statuses: Status[];
}
export const AnnualReport: React.FC<{
year: string;
}> = ({ year }) => {
const [response, setResponse] = useState<AnnualReportResponse | null>(null);
const [loading, setLoading] = useState(false);
const currentAccount = useAppSelector((state) =>
me ? state.accounts.get(me) : undefined,
);
const dispatch = useAppDispatch();
useEffect(() => {
setLoading(true);
apiRequestGet<AnnualReportResponse>(`v1/annual_reports/${year}`)
.then((data) => {
dispatch(importFetchedStatuses(data.statuses));
dispatch(importFetchedAccounts(data.accounts));
setResponse(data);
setLoading(false);
return apiRequestPost(`v1/annual_reports/${year}/read`);
})
.catch(() => {
setLoading(false);
});
}, [dispatch, year, setResponse, setLoading]);
if (loading) {
return <LoadingIndicator />;
}
const report = response?.annual_reports[0];
if (!report) {
return null;
}
return (
<div className='annual-report'>
<div className='annual-report__header'>
<h1>
<FormattedMessage
id='annual_report.summary.thanks'
defaultMessage='Thanks for being part of Mastodon!'
/>
</h1>
<p>
<FormattedMessage
id='annual_report.summary.here_it_is'
defaultMessage='Here is your {year} in review:'
values={{ year: report.year }}
/>
</p>
</div>
<div className='annual-report__bento annual-report__summary'>
<Archetype data={report.data.archetype} />
<HighlightedPost data={report.data.top_statuses} />
<Followers
data={report.data.time_series}
total={currentAccount?.followers_count}
/>
<MostUsedHashtag data={report.data.top_hashtags} />
<Percentile data={report.data.percentiles} />
<NewPosts data={report.data.time_series} />
</div>
</div>
);
};

View File

@ -0,0 +1,29 @@
import { FormattedMessage } from 'react-intl';
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
export const MostUsedApp: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const app = data[0];
if (!app) {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-app' />
);
}
return (
<div className='annual-report__bento__box annual-report__summary__most-used-app'>
<div className='annual-report__summary__most-used-app__icon'>
{app.name}
</div>
<div className='annual-report__summary__most-used-app__label'>
<FormattedMessage
id='annual_report.summary.most_used_app.most_used_app'
defaultMessage='most used app'
/>
</div>
</div>
);
};

View File

@ -0,0 +1,29 @@
import { FormattedMessage } from 'react-intl';
import type { NameAndCount } from 'flavours/glitch/models/annual_report';
export const MostUsedHashtag: React.FC<{
data: NameAndCount[];
}> = ({ data }) => {
const hashtag = data[0];
if (!hashtag) {
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag' />
);
}
return (
<div className='annual-report__bento__box annual-report__summary__most-used-hashtag'>
<div className='annual-report__summary__most-used-hashtag__hashtag'>
#{hashtag.name}
</div>
<div className='annual-report__summary__most-used-hashtag__label'>
<FormattedMessage
id='annual_report.summary.most_used_hashtag.most_used_hashtag'
defaultMessage='most used hashtag'
/>
</div>
</div>
);
};

View File

@ -0,0 +1,53 @@
import { FormattedNumber, FormattedMessage } from 'react-intl';
import ChatBubbleIcon from '@/material-icons/400-24px/chat_bubble.svg?react';
import type { TimeSeriesMonth } from 'flavours/glitch/models/annual_report';
export const NewPosts: React.FC<{
data: TimeSeriesMonth[];
}> = ({ data }) => {
const posts = data.reduce((sum, item) => sum + item.statuses, 0);
return (
<div className='annual-report__bento__box annual-report__summary__new-posts'>
<svg width={500} height={500}>
<defs>
<pattern
id='posts'
x='0'
y='0'
width='32'
height='35'
patternUnits='userSpaceOnUse'
>
<circle cx='12' cy='12' r='12' fill='var(--lime)' />
<ChatBubbleIcon
fill='var(--indigo-1)'
x='4'
y='4'
width='16'
height='16'
/>
</pattern>
</defs>
<rect
width={500}
height={500}
fill='url(#posts)'
style={{ opacity: 0.2 }}
/>
</svg>
<div className='annual-report__summary__new-posts__number'>
<FormattedNumber value={posts} />
</div>
<div className='annual-report__summary__new-posts__label'>
<FormattedMessage
id='annual_report.summary.new_posts.new_posts'
defaultMessage='new posts'
/>
</div>
</div>
);
};

View File

@ -0,0 +1,53 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { FormattedMessage, FormattedNumber } from 'react-intl';
import type { Percentiles } from 'flavours/glitch/models/annual_report';
export const Percentile: React.FC<{
data: Percentiles;
}> = ({ data }) => {
const percentile = data.statuses;
return (
<div className='annual-report__bento__box annual-report__summary__percentile'>
<FormattedMessage
id='annual_report.summary.percentile.text'
defaultMessage='<topLabel>That puts you in the top</topLabel><percentage></percentage><bottomLabel>of Mastodon users.</bottomLabel>'
values={{
topLabel: (str) => (
<div className='annual-report__summary__percentile__label'>
{str}
</div>
),
percentage: () => (
<div className='annual-report__summary__percentile__number'>
<FormattedNumber
value={percentile / 100}
style='percent'
maximumFractionDigits={1}
/>
</div>
),
bottomLabel: (str) => (
<div>
<div className='annual-report__summary__percentile__label'>
{str}
</div>
{percentile < 6 && (
<div className='annual-report__summary__percentile__footnote'>
<FormattedMessage
id='annual_report.summary.percentile.we_wont_tell_bernie'
defaultMessage="We won't tell Bernie."
/>
</div>
)}
</div>
),
}}
>
{(message) => <>{message}</>}
</FormattedMessage>
</div>
);
};

View File

@ -27,15 +27,19 @@ class ColumnSettings extends PureComponent {
return ( return (
<div className='column-settings'> <div className='column-settings'>
<div className='column-settings__row'> <section>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> <div className='column-settings__row'>
</div> <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div>
</section>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> <section>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div> </div>
</section>
</div> </div>
); );
} }

View File

@ -26,7 +26,7 @@ const messages = defineMessages({
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' }, singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Single choice' },
multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' }, multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
}); });

View File

@ -68,7 +68,7 @@ class FollowRequests extends ImmutablePureComponent {
); );
return ( return (
<Column bindToDocument={!multiColumn} icon='user-plus' iconComponent={PersonAddIcon} heading={intl.formatMessage(messages.heading)}> <Column bindToDocument={!multiColumn} icon='user-plus' iconComponent={PersonAddIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList <ScrollableList
scrollKey='follow_requests' scrollKey='follow_requests'
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}

View File

@ -151,8 +151,13 @@ export const InlineFollowSuggestions = ({ hidden }) => {
return; return;
} }
setCanScrollLeft(bodyRef.current.scrollLeft > 0); if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}
}, [setCanScrollRight, setCanScrollLeft, bodyRef]); }, [setCanScrollRight, setCanScrollLeft, bodyRef]);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {

View File

@ -9,6 +9,7 @@ import { connect } from 'react-redux';
import { throttle, escapeRegExp } from 'lodash'; import { throttle, escapeRegExp } from 'lodash';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
@ -340,7 +341,7 @@ class InteractionModal extends React.PureComponent {
static propTypes = { static propTypes = {
displayNameHtml: PropTypes.string, displayNameHtml: PropTypes.string,
url: PropTypes.string, url: PropTypes.string,
type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow']), type: PropTypes.oneOf(['reply', 'reblog', 'favourite', 'follow', 'vote']),
onSignupClick: PropTypes.func.isRequired, onSignupClick: PropTypes.func.isRequired,
signupUrl: PropTypes.string.isRequired, signupUrl: PropTypes.string.isRequired,
}; };
@ -377,6 +378,11 @@ class InteractionModal extends React.PureComponent {
title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />; title = <FormattedMessage id='interaction_modal.title.follow' defaultMessage='Follow {name}' values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />; actionDescription = <FormattedMessage id='interaction_modal.description.follow' defaultMessage='With an account on Mastodon, you can follow {name} to receive their posts in your home feed.' values={{ name }} />;
break; break;
case 'vote':
icon = <Icon id='tasks' icon={InsertChartIcon} />;
title = <FormattedMessage id='interaction_modal.title.vote' defaultMessage="Vote in {name}'s poll" values={{ name }} />;
actionDescription = <FormattedMessage id='interaction_modal.description.vote' defaultMessage='With an account on Mastodon, you can vote in this poll.' />;
break;
} }
let signupButton; let signupButton;

View File

@ -40,6 +40,7 @@ class ColumnSettings extends PureComponent {
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
const groupStr = <FormattedMessage id='notifications.column_settings.group' defaultMessage='Group' />;
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
@ -96,6 +97,10 @@ class ColumnSettings extends PureComponent {
<PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
<PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div> </div>
<div className='column-settings__row'>
<SettingToggle prefix='notifications' settings={settings} settingPath={['group', 'follow']} onChange={onChange} label={groupStr} />
</div>
</section> </section>
<section role='group' aria-labelledby='notifications-follow-request'> <section role='group' aria-labelledby='notifications-follow-request'>

View File

@ -56,11 +56,12 @@ const mapDispatchToProps = (dispatch) => ({
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
} }
} else if(path[0] === 'groupingBeta') {
dispatch(changeSetting(['notifications', ...path], checked));
dispatch(initializeNotifications());
} else { } else {
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
if(path[0] === 'group' && path[1] === 'follow') {
dispatch(initializeNotifications());
}
} }
}, },

View File

@ -0,0 +1,59 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import CelebrationIcon from '@/material-icons/400-24px/celebration.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import { Icon } from 'flavours/glitch/components/icon';
import type { NotificationGroupAnnualReport } from 'flavours/glitch/models/notification_group';
import { useAppDispatch } from 'flavours/glitch/store';
export const NotificationAnnualReport: React.FC<{
notification: NotificationGroupAnnualReport;
unread: boolean;
}> = ({ notification: { annualReport }, unread }) => {
const dispatch = useAppDispatch();
const year = annualReport.year;
const handleClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ANNUAL_REPORT',
modalProps: { year },
}),
);
}, [dispatch, year]);
return (
<div
role='button'
className={classNames(
'notification-group notification-group--link notification-group--annual-report focusable',
{ 'notification-group--unread': unread },
)}
tabIndex={0}
>
<div className='notification-group__icon'>
<Icon id='celebration' icon={CelebrationIcon} />
</div>
<div className='notification-group__main'>
<p>
<FormattedMessage
id='notification.annual_report.message'
defaultMessage="Your {year} #Wrapstodon awaits! Unveil your year's highlights and memorable moments on Mastodon!"
values={{ year }}
/>
</p>
<button onClick={handleClick} className='link-button'>
<FormattedMessage
id='notification.annual_report.view'
defaultMessage='View #Wrapstodon'
/>
</button>
</div>
</div>
);
};

View File

@ -1,16 +1,21 @@
import type { JSX } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
import { FollowersCounter } from 'flavours/glitch/components/counters'; import { FollowersCounter } from 'flavours/glitch/components/counters';
import { FollowButton } from 'flavours/glitch/components/follow_button'; import { FollowButton } from 'flavours/glitch/components/follow_button';
import { ShortNumber } from 'flavours/glitch/components/short_number'; import { ShortNumber } from 'flavours/glitch/components/short_number';
import { me } from 'flavours/glitch/initial_state';
import type { NotificationGroupFollow } from 'flavours/glitch/models/notification_group'; import type { NotificationGroupFollow } from 'flavours/glitch/models/notification_group';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
import { NotificationGroupWithStatus } from './notification_group_with_status'; import { NotificationGroupWithStatus } from './notification_group_with_status';
const labelRenderer: LabelRenderer = (displayedName, total) => { const labelRenderer: LabelRenderer = (displayedName, total, seeMoreHref) => {
if (total === 1) if (total === 1)
return ( return (
<FormattedMessage <FormattedMessage
@ -23,10 +28,12 @@ const labelRenderer: LabelRenderer = (displayedName, total) => {
return ( return (
<FormattedMessage <FormattedMessage
id='notification.follow.name_and_others' id='notification.follow.name_and_others'
defaultMessage='{name} and {count, plural, one {# other} other {# others}} followed you' defaultMessage='{name} and <a>{count, plural, one {# other} other {# others}}</a> followed you'
values={{ values={{
name: displayedName, name: displayedName,
count: total - 1, count: total - 1,
a: (chunks) =>
seeMoreHref ? <Link to={seeMoreHref}>{chunks}</Link> : chunks,
}} }}
/> />
); );
@ -46,6 +53,10 @@ export const NotificationFollow: React.FC<{
notification: NotificationGroupFollow; notification: NotificationGroupFollow;
unread: boolean; unread: boolean;
}> = ({ notification, unread }) => { }> = ({ notification, unread }) => {
const username = useAppSelector(
(state) => state.accounts.getIn([me, 'username']) as string,
);
let actions: JSX.Element | undefined; let actions: JSX.Element | undefined;
let additionalContent: JSX.Element | undefined; let additionalContent: JSX.Element | undefined;
@ -68,6 +79,7 @@ export const NotificationFollow: React.FC<{
timestamp={notification.latest_page_notification_at} timestamp={notification.latest_page_notification_at}
count={notification.notifications_count} count={notification.notifications_count}
labelRenderer={labelRenderer} labelRenderer={labelRenderer}
labelSeeMoreHref={`/@${username}/followers`}
unread={unread} unread={unread}
actions={actions} actions={actions}
additionalContent={additionalContent} additionalContent={additionalContent}

View File

@ -9,6 +9,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { NotificationAdminReport } from './notification_admin_report'; import { NotificationAdminReport } from './notification_admin_report';
import { NotificationAdminSignUp } from './notification_admin_sign_up'; import { NotificationAdminSignUp } from './notification_admin_sign_up';
import { NotificationAnnualReport } from './notification_annual_report';
import { NotificationFavourite } from './notification_favourite'; import { NotificationFavourite } from './notification_favourite';
import { NotificationFollow } from './notification_follow'; import { NotificationFollow } from './notification_follow';
import { NotificationFollowRequest } from './notification_follow_request'; import { NotificationFollowRequest } from './notification_follow_request';
@ -152,6 +153,14 @@ export const NotificationGroup: React.FC<{
/> />
); );
break; break;
case 'annual_report':
content = (
<NotificationAnnualReport
unread={unread}
notification={notificationGroup}
/>
);
break;
default: default:
return null; return null;
} }

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { JSX } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -14,6 +14,8 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react'; import StarIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import { replyCompose } from 'flavours/glitch/actions/compose'; import { replyCompose } from 'flavours/glitch/actions/compose';
import { toggleReblog, toggleFavourite } from 'flavours/glitch/actions/interactions'; import { toggleReblog, toggleFavourite } from 'flavours/glitch/actions/interactions';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
@ -161,16 +163,20 @@ class Footer extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll); replyTitle = intl.formatMessage(messages.replyAll);
} }
let reblogTitle = ''; let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) { if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private); reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
} else if (publicStatus) { } else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog); reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) { } else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private); reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else { } else {
reblogTitle = intl.formatMessage(messages.cannot_reblog); reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
} }
let replyButton = null; let replyButton = null;
@ -201,7 +207,7 @@ class Footer extends ImmutablePureComponent {
return ( return (
<div className='picture-in-picture__footer'> <div className='picture-in-picture__footer'>
{replyButton} {replyButton}
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={RepeatIcon} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={status.get('url')} />} {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={status.get('url')} />}
</div> </div>

View File

@ -54,6 +54,7 @@ export const DetailedStatus: React.FC<{
domain: string; domain: string;
showMedia?: boolean; showMedia?: boolean;
withLogo?: boolean; withLogo?: boolean;
overrideDisplayName?: React.ReactNode;
pictureInPicture: any; pictureInPicture: any;
onToggleHidden?: (status: any) => void; onToggleHidden?: (status: any) => void;
onToggleMediaVisibility?: () => void; onToggleMediaVisibility?: () => void;
@ -70,6 +71,7 @@ export const DetailedStatus: React.FC<{
domain, domain,
showMedia, showMedia,
withLogo, withLogo,
overrideDisplayName,
pictureInPicture, pictureInPicture,
onToggleMediaVisibility, onToggleMediaVisibility,
onToggleHidden, onToggleHidden,
@ -204,7 +206,7 @@ export const DetailedStatus: React.FC<{
) { ) {
media.push(<AttachmentList media={status.get('media_attachments')} />); media.push(<AttachmentList media={status.get('media_attachments')} />);
} else if ( } else if (
['image', 'gifv'].includes( ['image', 'gifv', 'unknown'].includes(
status.getIn(['media_attachments', 0, 'type']) as string, status.getIn(['media_attachments', 0, 'type']) as string,
) || ) ||
status.get('media_attachments').size > 1 status.get('media_attachments').size > 1
@ -297,6 +299,7 @@ export const DetailedStatus: React.FC<{
<PollContainer <PollContainer
pollId={status.get('poll')} pollId={status.get('poll')}
// @ts-expect-error -- Poll/PollContainer is not typed yet // @ts-expect-error -- Poll/PollContainer is not typed yet
status={status}
lang={status.get('language')} lang={status.get('language')}
/>, />,
); );
@ -385,7 +388,11 @@ export const DetailedStatus: React.FC<{
<div className='detailed-status__display-avatar'> <div className='detailed-status__display-avatar'>
<Avatar account={status.get('account')} size={46} /> <Avatar account={status.get('account')} size={46} />
</div> </div>
<DisplayName account={status.get('account')} localDomain={domain} />
{overrideDisplayName ?? (
<DisplayName account={status.get('account')} localDomain={domain} />
)}
{withLogo && ( {withLogo && (
<> <>
<div className='spacer' /> <div className='spacer' />

View File

@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { AnnualReport } from 'flavours/glitch/features/annual_report';
const AnnualReportModal: React.FC<{
year: string;
onChangeBackgroundColor: (arg0: string) => void;
}> = ({ year, onChangeBackgroundColor }) => {
useEffect(() => {
onChangeBackgroundColor('var(--indigo-1)');
}, [onChangeBackgroundColor]);
return (
<div className='modal-root__modal annual-report-modal'>
<AnnualReport year={year} />
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AnnualReportModal;

View File

@ -20,6 +20,7 @@ import {
SubscribedLanguagesModal, SubscribedLanguagesModal,
ClosedRegistrationsModal, ClosedRegistrationsModal,
IgnoreNotificationsModal, IgnoreNotificationsModal,
AnnualReportModal,
} from 'flavours/glitch/features/ui/util/async-components'; } from 'flavours/glitch/features/ui/util/async-components';
import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar'; import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
@ -82,6 +83,7 @@ export const MODAL_COMPONENTS = {
'INTERACTION': InteractionModal, 'INTERACTION': InteractionModal,
'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal,
'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal,
'ANNUAL_REPORT': AnnualReportModal,
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@ -116,6 +116,7 @@ export const MuteModal = ({ accountId, acct }) => {
<div className='safety-action-modal__bottom__collapsible'> <div className='safety-action-modal__bottom__collapsible'>
<div className='safety-action-modal__field-group'> <div className='safety-action-modal__field-group'>
<RadioButtonLabel name='duration' value='0' label={intl.formatMessage(messages.indefinite)} currentValue={muteDuration} onChange={handleChangeMuteDuration} /> <RadioButtonLabel name='duration' value='0' label={intl.formatMessage(messages.indefinite)} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
<RadioButtonLabel name='duration' value='21600' label={intl.formatMessage(messages.hours, { number: 6 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
<RadioButtonLabel name='duration' value='86400' label={intl.formatMessage(messages.hours, { number: 24 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} /> <RadioButtonLabel name='duration' value='86400' label={intl.formatMessage(messages.hours, { number: 24 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
<RadioButtonLabel name='duration' value='604800' label={intl.formatMessage(messages.days, { number: 7 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} /> <RadioButtonLabel name='duration' value='604800' label={intl.formatMessage(messages.days, { number: 7 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />
<RadioButtonLabel name='duration' value='2592000' label={intl.formatMessage(messages.days, { number: 30 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} /> <RadioButtonLabel name='duration' value='2592000' label={intl.formatMessage(messages.days, { number: 30 })} currentValue={muteDuration} onChange={handleChangeMuteDuration} />

View File

@ -229,3 +229,7 @@ export function NotificationRequest () {
export function LinkTimeline () { export function LinkTimeline () {
return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline'); return import(/*webpackChunkName: "features/glitch/link_timeline" */'../../link_timeline');
} }
export function AnnualReportModal () {
return import(/*webpackChunkName: "flavours/glitch/async/modals/annual_report_modal" */'../components/annual_report_modal');
}

View File

@ -154,7 +154,5 @@
"status.is_poll": "Dieser Toot ist eine Umfrage", "status.is_poll": "Dieser Toot ist eine Umfrage",
"status.local_only": "Nur auf deiner Instanz sichtbar", "status.local_only": "Nur auf deiner Instanz sichtbar",
"status.show_filter_reason": "Trotzdem anzeigen", "status.show_filter_reason": "Trotzdem anzeigen",
"status.show_less": "Weniger anzeigen",
"status.show_more": "Mehr anzeigen",
"status.uncollapse": "Ausklappen" "status.uncollapse": "Ausklappen"
} }

View File

@ -161,7 +161,5 @@
"status.local_only": "Only visible from your instance", "status.local_only": "Only visible from your instance",
"status.react": "React", "status.react": "React",
"status.show_filter_reason": "Show anyway", "status.show_filter_reason": "Show anyway",
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.uncollapse": "Uncollapse" "status.uncollapse": "Uncollapse"
} }

View File

@ -154,7 +154,5 @@
"status.is_poll": "Esta publicación es una encuesta", "status.is_poll": "Esta publicación es una encuesta",
"status.local_only": "Sólo visible para tu instancia", "status.local_only": "Sólo visible para tu instancia",
"status.show_filter_reason": "Mostrar de todos modos", "status.show_filter_reason": "Mostrar de todos modos",
"status.show_less": "Mostrar menos",
"status.show_more": "Mostrar más",
"status.uncollapse": "Descolapsar" "status.uncollapse": "Descolapsar"
} }

View File

@ -154,7 +154,5 @@
"status.is_poll": "이 글은 설문입니다", "status.is_poll": "이 글은 설문입니다",
"status.local_only": "당신의 서버에서만 보입니다", "status.local_only": "당신의 서버에서만 보입니다",
"status.show_filter_reason": "그냥 표시하기", "status.show_filter_reason": "그냥 표시하기",
"status.show_less": "접기",
"status.show_more": "더보기",
"status.uncollapse": "펼치기" "status.uncollapse": "펼치기"
} }

View File

@ -1,5 +1,88 @@
{ {
"about.fork_disclaimer": "Glitch-soc - это бесплатное программное обеспечение с открытым исходным кодом, обращенное от Mastodon.", "about.fork_disclaimer": "Glitch-soc — это свободное программное обеспечение с открытым исходным кодом, ответвлённое от Mastodon.",
"account.follows": "Подписки",
"account.follows_you": "Подписан(а) на вас",
"account.suspended_disclaimer_full": "Этот пользователь был заблокирован модератором.",
"boost_modal.missing_description": "Этот пост содержит медиафайлы без описания",
"column.favourited_by": "Добавили в избранное",
"column.reblogged_by": "Продвинули",
"column_header.profile": "Профиль",
"column_subheading.lists": "Списки",
"column_subheading.navigation": "Навигация",
"compose.attach.doodle": "Нарисовать что-нибудь",
"compose.change_federation": "Изменить настройки федерации",
"compose.content-type.html": "HTML",
"compose.content-type.markdown": "Markdown",
"compose.content-type.plain": "Простой текст",
"compose.disable_threaded_mode": "Отключить режим треда",
"compose.enable_threaded_mode": "Включить режим треда",
"confirmation_modal.do_not_ask_again": "Больше не спрашивать подтверждение",
"confirmations.deprecated_settings.confirm": "Использовать настройки Mastodon",
"confirmations.missing_media_description.confirm": "Всё равно опубликовать",
"direct.group_by_conversations": "Группировать по перепискам",
"endorsed_accounts_editor.endorsed_accounts": "Рекомендованные аккаунты",
"favourite_modal.favourite": "Добавить пост в избранное?",
"federation.federated.long": "Разрешить делиться этим постом с другими серверами",
"federation.local_only.long": "Запретить делиться этим постом с другими серверами",
"home.column_settings.advanced": "Продвинутые настройки",
"home.column_settings.filter_regex": "Фильтр по регулярным выражениям",
"keyboard_shortcuts.bookmark": "добавить закладку",
"keyboard_shortcuts.toggle_collapse": "свернуть/развернуть пост",
"moved_to_warning": "Этот аккаунт переехал на {moved_to_link}, и скорее всего не принимает новых подписчиков.",
"navigation_bar.app_settings": "Настройки приложения",
"navigation_bar.keyboard_shortcuts": "Сочетания клавиш",
"notification.markForDeletion": "Отметить для удаления",
"notification_purge.btn_all": "Выбрать все",
"notification_purge.btn_apply": "Удалить выбранное",
"notification_purge.btn_invert": "Инвертировать выбор",
"notification_purge.btn_none": "Отменить выбор",
"notification_purge.start": "Войти в режим очистки уведомлений",
"notifications.column_settings.filter_bar.show_bar": "Показать панель фильтров",
"notifications.marked_clear": "Удалить выбранные уведомления",
"notifications.marked_clear_confirmation": "Вы уверены, что хотите безвозвратно удалить все выбранные уведомления?",
"settings.auto_collapse": "Сворачивать автоматически",
"settings.auto_collapse_all": "Всё",
"settings.auto_collapse_height": "Высота (в пикселях) для того, чтобы пост считался длинным",
"settings.auto_collapse_lengthy": "Длинные посты",
"settings.auto_collapse_media": "Посты с медиафайлами",
"settings.auto_collapse_notifications": "Уведомления",
"settings.auto_collapse_reblogs": "Продвижения",
"settings.auto_collapse_replies": "Ответы",
"settings.close": "Закрыть",
"settings.collapsed_statuses": "Сворачивание постов",
"settings.compose_box_opts": "Форма постинга",
"settings.content_warnings": "Content warnings", "settings.content_warnings": "Content warnings",
"settings.preferences": "Preferences" "settings.content_warnings.regexp": "Регулярное выражение",
"settings.content_warnings_unfold_opts": "Автоматическое раскрытие",
"settings.deprecated_setting": "Эта опция теперь может быть включена в {settings_page_link} Mastodon",
"settings.enable_collapsed": "Включить сворачивание постов",
"settings.general": "Общие",
"settings.hicolor_privacy_icons": "Цветные значки публичности поста",
"settings.hicolor_privacy_icons.hint": "Отображать значки публичности поста в ярких и различимых цветах",
"settings.media": "Медиафайлы",
"settings.notifications.favicon_badge": "Индикатор уведомлений на иконке сайта",
"settings.notifications_opts": "Опции уведомлений",
"settings.pop_in_left": "Слева",
"settings.pop_in_player": "Включить плавающий плеер",
"settings.pop_in_position": "Расположение плавающего плеера:",
"settings.pop_in_right": "Справа",
"settings.preferences": "Preferences",
"settings.shared_settings_link": "настройках пользователя",
"settings.show_reply_counter": "Показывать приблизительное число ответов",
"settings.side_arm": "Дополнительная кнопка постинга:",
"settings.side_arm.none": "Нет",
"settings.side_arm_reply_mode": "При ответе на пост дополнительная кнопка постинга должна:",
"settings.status_icons": "Значки постов",
"settings.status_icons_language": "Индикатор языка",
"settings.status_icons_local_only": "Индикатор нефедерируемого поста",
"settings.status_icons_media": "Индикаторы медиафайлов и опросов",
"settings.status_icons_reply": "Индикатор ответа",
"settings.status_icons_visibility": "Индикатор публичности поста",
"settings.tag_misleading_links": "Помечать обманчивые ссылки",
"status.collapse": "Свернуть",
"status.hide": "Скрыть пост",
"status.in_reply_to": "Этот пост является ответом",
"status.is_poll": "Этот пост содержит опрос",
"status.show_filter_reason": "Всё равно показать",
"status.uncollapse": "Развернуть"
} }

View File

@ -153,7 +153,5 @@
"status.is_poll": "此嘟文是投票", "status.is_poll": "此嘟文是投票",
"status.local_only": "此嘟文仅本站可见", "status.local_only": "此嘟文仅本站可见",
"status.show_filter_reason": "仍然显示", "status.show_filter_reason": "仍然显示",
"status.show_less": "部分显示",
"status.show_more": "完全显示",
"status.uncollapse": "展开" "status.uncollapse": "展开"
} }

View File

@ -0,0 +1,44 @@
export interface Percentiles {
followers: number;
statuses: number;
}
export interface NameAndCount {
name: string;
count: number;
}
export interface TimeSeriesMonth {
month: number;
statuses: number;
following: number;
followers: number;
}
export interface TopStatuses {
by_reblogs: number;
by_favourites: number;
by_replies: number;
}
export type Archetype =
| 'lurker'
| 'booster'
| 'pollster'
| 'replier'
| 'oracle';
interface AnnualReportV1 {
most_used_apps: NameAndCount[];
percentiles: Percentiles;
top_hashtags: NameAndCount[];
top_statuses: TopStatuses;
time_series: TimeSeriesMonth[];
archetype: Archetype;
}
export interface AnnualReport {
year: number;
schema_version: number;
data: AnnualReportV1;
}

View File

@ -1,6 +1,7 @@
import type { import type {
ApiAccountRelationshipSeveranceEventJSON, ApiAccountRelationshipSeveranceEventJSON,
ApiAccountWarningJSON, ApiAccountWarningJSON,
ApiAnnualReportEventJSON,
BaseNotificationGroupJSON, BaseNotificationGroupJSON,
ApiNotificationGroupJSON, ApiNotificationGroupJSON,
ApiNotificationJSON, ApiNotificationJSON,
@ -66,6 +67,12 @@ export interface NotificationGroupSeveredRelationships
event: AccountRelationshipSeveranceEvent; event: AccountRelationshipSeveranceEvent;
} }
type AnnualReportEvent = ApiAnnualReportEventJSON;
export interface NotificationGroupAnnualReport
extends BaseNotification<'annual_report'> {
annualReport: AnnualReportEvent;
}
interface Report extends Omit<ApiReportJSON, 'target_account'> { interface Report extends Omit<ApiReportJSON, 'target_account'> {
targetAccountId: string; targetAccountId: string;
} }
@ -88,7 +95,8 @@ export type NotificationGroup =
| NotificationGroupModerationWarning | NotificationGroupModerationWarning
| NotificationGroupSeveredRelationships | NotificationGroupSeveredRelationships
| NotificationGroupAdminSignUp | NotificationGroupAdminSignUp
| NotificationGroupAdminReport; | NotificationGroupAdminReport
| NotificationGroupAnnualReport;
function createReportFromJSON(reportJSON: ApiReportJSON): Report { function createReportFromJSON(reportJSON: ApiReportJSON): Report {
const { target_account, ...report } = reportJSON; const { target_account, ...report } = reportJSON;
@ -114,6 +122,12 @@ function createAccountRelationshipSeveranceEventFromJSON(
return eventJson; return eventJson;
} }
function createAnnualReportEventFromJSON(
eventJson: ApiAnnualReportEventJSON,
): AnnualReportEvent {
return eventJson;
}
export function createNotificationGroupFromJSON( export function createNotificationGroupFromJSON(
groupJson: ApiNotificationGroupJSON, groupJson: ApiNotificationGroupJSON,
): NotificationGroup { ): NotificationGroup {
@ -148,7 +162,6 @@ export function createNotificationGroupFromJSON(
event: createAccountRelationshipSeveranceEventFromJSON(group.event), event: createAccountRelationshipSeveranceEventFromJSON(group.event),
sampleAccountIds, sampleAccountIds,
}; };
case 'moderation_warning': { case 'moderation_warning': {
const { moderation_warning, ...groupWithoutModerationWarning } = group; const { moderation_warning, ...groupWithoutModerationWarning } = group;
return { return {
@ -157,6 +170,14 @@ export function createNotificationGroupFromJSON(
sampleAccountIds, sampleAccountIds,
}; };
} }
case 'annual_report': {
const { annual_report, ...groupWithoutAnnualReport } = group;
return {
...groupWithoutAnnualReport,
annualReport: createAnnualReportEventFromJSON(annual_report),
sampleAccountIds,
};
}
default: default:
return { return {
sampleAccountIds, sampleAccountIds,

View File

@ -57,7 +57,10 @@ export const accountsReducer: Reducer<typeof initialState> = (
return state.setIn([action.payload.id, 'hidden'], false); return state.setIn([action.payload.id, 'hidden'], false);
else if (importAccounts.match(action)) else if (importAccounts.match(action))
return normalizeAccounts(state, action.payload.accounts); return normalizeAccounts(state, action.payload.accounts);
else if (followAccountSuccess.match(action)) { else if (
followAccountSuccess.match(action) &&
!action.payload.alreadyFollowing
) {
return state return state
.update(action.payload.relationship.id, (account) => .update(action.payload.relationship.id, (account) =>
account?.update('followers_count', (n) => n + 1), account?.update('followers_count', (n) => n + 1),

View File

@ -21,7 +21,6 @@ import {
unmountNotifications, unmountNotifications,
refreshStaleNotificationGroups, refreshStaleNotificationGroups,
pollRecentNotifications, pollRecentNotifications,
shouldGroupNotificationType,
} from 'flavours/glitch/actions/notification_groups'; } from 'flavours/glitch/actions/notification_groups';
import { import {
disconnectTimeline, disconnectTimeline,
@ -30,6 +29,7 @@ import {
import type { import type {
ApiNotificationJSON, ApiNotificationJSON,
ApiNotificationGroupJSON, ApiNotificationGroupJSON,
NotificationType,
} from 'flavours/glitch/api_types/notifications'; } from 'flavours/glitch/api_types/notifications';
import { compareId } from 'flavours/glitch/compare_id'; import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems } from 'flavours/glitch/initial_state'; import { usePendingItems } from 'flavours/glitch/initial_state';
@ -205,8 +205,9 @@ function mergeGapsAround(
function processNewNotification( function processNewNotification(
groups: NotificationGroupsState['groups'], groups: NotificationGroupsState['groups'],
notification: ApiNotificationJSON, notification: ApiNotificationJSON,
groupedTypes: NotificationType[],
) { ) {
if (!shouldGroupNotificationType(notification.type)) { if (!groupedTypes.includes(notification.type)) {
notification = { notification = {
...notification, ...notification,
group_key: `ungrouped-${notification.id}`, group_key: `ungrouped-${notification.id}`,
@ -476,11 +477,13 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
trimNotifications(state); trimNotifications(state);
}) })
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => { .addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload; if (action.payload) {
if (notification) { const { notification, groupedTypes } = action.payload;
processNewNotification( processNewNotification(
usePendingItems ? state.pendingGroups : state.groups, usePendingItems ? state.pendingGroups : state.groups,
notification, notification,
groupedTypes,
); );
updateLastReadId(state); updateLastReadId(state);
trimNotifications(state); trimNotifications(state);

View File

@ -84,6 +84,10 @@ const initialState = ImmutableMap({
'admin.sign_up': true, 'admin.sign_up': true,
'admin.report': true, 'admin.report': true,
}), }),
group: ImmutableMap({
follow: true
}),
}), }),
firehose: ImmutableMap({ firehose: ImmutableMap({

View File

@ -52,4 +52,7 @@ export const selectSettingsNotificationsMinimizeFilteredBanner = (
) => ) =>
state.settings.getIn(['notifications', 'minimizeFilteredBanner']) as boolean; state.settings.getIn(['notifications', 'minimizeFilteredBanner']) as boolean;
export const selectSettingsNotificationsGroupFollows = (state: RootState) =>
state.settings.getIn(['notifications', 'group', 'follow']) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */

View File

@ -0,0 +1,335 @@
:root {
--indigo-1: #17063b;
--indigo-2: #2f0c7a;
--indigo-3: #562cfc;
--indigo-5: #858afa;
--indigo-6: #cccfff;
--lime: #baff3b;
--goldenrod-2: #ffc954;
}
.annual-report {
flex: 0 0 auto;
background: var(--indigo-1);
padding: 24px;
&__header {
margin-bottom: 16px;
h1 {
font-size: 25px;
font-weight: 600;
line-height: 30px;
color: var(--lime);
margin-bottom: 8px;
}
p {
font-size: 16px;
font-weight: 600;
line-height: 20px;
color: var(--indigo-6);
}
}
&__bento {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: minmax(0, auto) minmax(0, 1fr) minmax(0, auto) minmax(
0,
auto
);
&__box {
padding: 16px;
border-radius: 8px;
background: var(--indigo-2);
color: var(--indigo-5);
}
}
&__summary {
&__most-boosted-post {
grid-column: span 2;
grid-row: span 2;
padding: 0;
.status__content,
.content-warning {
color: var(--indigo-6);
}
.detailed-status {
border: 0;
}
.content-warning {
border: 0;
background: var(--indigo-1);
.link-button {
color: var(--indigo-5);
}
}
.detailed-status__meta__line {
border-bottom-color: var(--indigo-3);
}
.detailed-status__meta {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.detailed-status__meta,
.poll__footer,
.poll__link,
.detailed-status .logo,
.detailed-status__display-name {
color: var(--indigo-5);
}
.detailed-status__meta .animated-number,
.detailed-status__display-name strong {
color: var(--indigo-6);
}
.poll__chart {
background-color: var(--indigo-3);
&.leading {
background-color: var(--goldenrod-2);
}
}
}
&__followers {
grid-column: span 1;
text-align: center;
position: relative;
overflow: hidden;
padding-block-start: 24px;
padding-block-end: 24px;
--sparkline-gradient-top: rgba(86, 44, 252, 50%);
--sparkline-gradient-bottom: rgba(86, 44, 252, 0%);
&__foreground {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
z-index: 1;
}
&__number {
font-size: 31px;
font-weight: 600;
line-height: 37px;
color: var(--lime);
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
color: var(--indigo-6);
}
&__footnote {
display: block;
font-weight: 400;
opacity: 0.5;
}
svg {
position: absolute;
bottom: 0;
inset-inline-end: 0;
pointer-events: none;
z-index: 0;
height: 70%;
width: auto;
path:first-child {
fill: url('#gradient') !important;
fill-opacity: 1 !important;
}
path:last-child {
stroke: var(--indigo-3) !important;
fill: none !important;
}
}
}
&__archetype {
grid-column: span 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
padding: 0;
img {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
&__label {
padding: 16px;
padding-bottom: 8px;
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--lime);
}
}
&__most-used-app {
grid-column: span 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
&__label {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--indigo-6);
}
&__icon {
font-size: 14px;
line-height: 17px;
font-weight: 600;
color: var(--goldenrod-2);
}
}
&__percentile {
grid-row: span 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
text-align: center;
text-wrap: balance;
padding: 16px 8px;
&__label {
font-size: 14px;
line-height: 17px;
}
&__number {
font-size: 61px;
font-weight: 600;
line-height: 73px;
color: var(--goldenrod-2);
}
&__footnote {
font-size: 11px;
line-height: 14px;
opacity: 0.5;
}
}
&__new-posts {
grid-column: span 2;
text-align: center;
position: relative;
overflow: hidden;
&__label {
font-size: 20px;
font-weight: 600;
line-height: 24px;
color: var(--indigo-6);
z-index: 1;
position: relative;
}
&__number {
font-size: 76px;
font-weight: 600;
line-height: 91px;
color: var(--goldenrod-2);
z-index: 1;
position: relative;
}
svg {
position: absolute;
inset-inline-start: -7px;
top: -4px;
z-index: 0;
}
}
&__most-used-hashtag {
grid-column: span 2;
text-align: center;
overflow: hidden;
&__hashtag {
font-size: 42px;
font-weight: 600;
line-height: 58px;
color: var(--indigo-6);
margin-inline-start: -100%;
margin-inline-end: -100%;
}
&__label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
}
}
}
}
.annual-report-modal {
max-width: 480px;
background: var(--indigo-1);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
.loading-indicator .circular-progress {
color: var(--lime);
}
@media screen and (max-width: $no-columns-breakpoint) {
border-bottom: 0;
border-radius: 16px 16px 0 0;
}
}
.notification-group--annual-report {
.notification-group__icon {
color: var(--lime);
}
.notification-group__main .link-button {
font-weight: 500;
color: var(--lime);
}
}

View File

@ -15,6 +15,7 @@
@import 'polls'; @import 'polls';
@import 'modal'; @import 'modal';
@import 'emoji_picker'; @import 'emoji_picker';
@import 'annual_reports';
@import 'about'; @import 'about';
@import 'tables'; @import 'tables';
@import 'admin'; @import 'admin';

View File

@ -1399,9 +1399,9 @@ body > [data-popper-placement] {
} }
.status__content__spoiler-link { .status__content__spoiler-link {
display: inline-flex; // glitch: media icon in spoiler button display: inline-block;
border-radius: 2px; border-radius: 2px;
background: $action-button-color; // glitch: design used in more places background: transparent;
border: 0; border: 0;
color: $inverted-text-color; color: $inverted-text-color;
font-weight: 700; font-weight: 700;
@ -1411,23 +1411,6 @@ body > [data-popper-placement] {
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;
vertical-align: top; vertical-align: top;
align-items: center; // glitch: content indicator
&:hover {
// glitch: design used in more places
background: lighten($action-button-color, 7%);
text-decoration: none;
}
.status__content__spoiler-icon {
display: inline-block;
margin-inline-start: 5px;
border-inline-start: 1px solid currentColor;
padding: 0;
padding-inline-start: 4px;
width: 16px;
height: 16px;
}
} }
.status__wrapper--filtered { .status__wrapper--filtered {
@ -1878,7 +1861,8 @@ body > [data-popper-placement] {
.status__wrapper-direct, .status__wrapper-direct,
.notification-ungrouped--direct, .notification-ungrouped--direct,
.notification-group--direct { .notification-group--direct,
.notification-group--annual-report {
background: rgba($ui-highlight-color, 0.05); background: rgba($ui-highlight-color, 0.05);
&:focus { &:focus {
@ -1952,6 +1936,14 @@ body > [data-popper-placement] {
margin-bottom: 16px; margin-bottom: 16px;
} }
.content-warning {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.logo { .logo {
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -2954,6 +2946,7 @@ a.account__display-name {
flex: 0 1 auto; flex: 0 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
contain: inline-size layout paint style;
@media screen and (min-width: $no-gap-breakpoint) { @media screen and (min-width: $no-gap-breakpoint) {
max-width: 600px; max-width: 600px;
@ -4250,6 +4243,7 @@ input.glitch-setting-text {
overflow: hidden; overflow: hidden;
border: 1px solid var(--background-border-color); border: 1px solid var(--background-border-color);
border-radius: 8px; border-radius: 8px;
contain: inline-size layout paint style;
&.bottomless { &.bottomless {
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
@ -6265,7 +6259,8 @@ a.status-card {
inset-inline-start: 0; inset-inline-start: 0;
inset-inline-end: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
background: rgba($base-overlay-background, 0.7); opacity: 0.9;
background: $base-overlay-background;
transition: background 0.5s; transition: background 0.5s;
} }
@ -6292,6 +6287,7 @@ a.status-card {
pointer-events: auto; pointer-events: auto;
user-select: text; user-select: text;
display: flex; display: flex;
max-width: 100vw;
@media screen and (width <= $mobile-breakpoint) { @media screen and (width <= $mobile-breakpoint) {
margin-top: auto; margin-top: auto;
@ -11377,21 +11373,17 @@ noscript {
color: $darker-text-color; color: $darker-text-color;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
max-height: 4 * 22px; max-height: none;
overflow: hidden; overflow: hidden;
p {
display: none;
&:first-child {
display: initial;
}
}
p, p,
a { a {
color: inherit; color: inherit;
} }
p {
margin-bottom: 8px;
}
} }
.reply-indicator__attachments { .reply-indicator__attachments {
@ -11676,19 +11668,21 @@ noscript {
} }
.content-warning { .content-warning {
display: block;
box-sizing: border-box; box-sizing: border-box;
background: rgba($ui-highlight-color, 0.05); background: rgba($ui-highlight-color, 0.05);
color: $secondary-text-color; color: $secondary-text-color;
border-top: 1px solid; border: 1px solid rgba($ui-highlight-color, 0.15);
border-bottom: 1px solid; border-radius: 8px;
border-color: rgba($ui-highlight-color, 0.15);
padding: 8px (5px + 8px); padding: 8px (5px + 8px);
position: relative; position: relative;
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
cursor: pointer;
p { p {
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500;
} }
.link-button { .link-button {
@ -11697,31 +11691,22 @@ noscript {
font-weight: 500; font-weight: 500;
} }
&::before, &--filter {
&::after { color: $darker-text-color;
content: '';
display: block; p {
position: absolute; font-weight: normal;
height: 100%; }
background: url('~images/warning-stripes.svg') repeat-y;
width: 5px; .filter-name {
top: 0; font-weight: 500;
color: $secondary-text-color;
}
} }
&::before { .status__content__spoiler-icon {
border-start-start-radius: 4px; float: inline-end;
border-end-start-radius: 4px; width: 20px;
inset-inline-start: 0; height: 20px;
}
&::after {
border-start-end-radius: 4px;
border-end-end-radius: 4px;
inset-inline-end: 0;
}
&--filter::before,
&--filter::after {
background-image: url('~images/filter-stripes.svg');
} }
} }

View File

@ -23,6 +23,8 @@ code {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
height: 160px; height: 160px;
max-width: 566px;
margin-inline: auto;
&::after { &::after {
content: ''; content: '';

View File

@ -76,4 +76,7 @@ body {
--background-color-tint: rgba(255, 255, 255, 80%); --background-color-tint: rgba(255, 255, 255, 80%);
--background-filter: blur(10px); --background-filter: blur(10px);
--on-surface-color: #{transparentize($ui-base-color, 0.65)}; --on-surface-color: #{transparentize($ui-base-color, 0.65)};
--rich-text-container-color: rgba(255, 216, 231, 100%);
--rich-text-text-color: rgba(114, 47, 83, 100%);
--rich-text-decorations-color: rgba(255, 175, 212, 100%);
} }

View File

@ -2,9 +2,29 @@
.e-content, .e-content,
.edit-indicator__content, .edit-indicator__content,
.reply-indicator__content { .reply-indicator__content {
code {
background: var(--rich-text-container-color);
padding: 4px;
border-radius: 4px;
color: var(--rich-text-text-color);
font-size: 0.85em;
}
pre {
background: var(--rich-text-container-color);
padding: 8px;
border-radius: 4px;
color: var(--rich-text-text-color);
code {
padding: 0;
background: transparent;
}
}
pre, pre,
blockquote { blockquote {
margin-bottom: 20px; margin-bottom: 22px;
white-space: pre-wrap; white-space: pre-wrap;
unicode-bidi: plaintext; unicode-bidi: plaintext;
@ -14,19 +34,45 @@
} }
blockquote { blockquote {
padding-inline-start: 10px; padding-inline-start: 32px;
border-inline-start: 3px solid $darker-text-color; color: var(--rich-text-text-color);
color: $darker-text-color;
white-space: normal; white-space: normal;
position: relative;
p:last-child { &::before {
display: block;
content: '';
width: 24px;
height: 20px;
mask-image: url('~images/quote.svg');
background-color: var(--rich-text-decorations-color);
position: absolute;
inset-inline-start: 0;
top: 0;
}
blockquote {
margin-top: 4px;
border-inline-start: 3px solid var(--rich-text-decorations-color);
padding-inline-start: 16px;
&::before {
display: none;
}
}
p:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
} }
& > ul, & > ul,
& > ol { & > ol {
margin-bottom: 20px; margin-bottom: 22px;
&:last-child {
margin-bottom: 0;
}
} }
h1, h1,
@ -76,7 +122,15 @@
ul, ul,
ol { ol {
margin-inline-start: 2em; padding-inline-start: 24px;
li {
padding-inline-start: 8px;
&::marker {
text-align: end;
}
}
p { p {
margin: 0; margin: 0;
@ -84,7 +138,11 @@
} }
ul { ul {
list-style-type: disc; list-style-type: '';
li::marker {
text-align: start;
}
} }
ol { ol {

View File

@ -90,6 +90,10 @@ body.rtl {
direction: rtl; direction: rtl;
} }
.column-back-button__icon {
transform: scale(-1, 1);
}
.simple_form select { .simple_form select {
background: $ui-base-color background: $ui-base-color
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>")

View File

@ -122,4 +122,7 @@ $dismiss-overlay-width: 4rem;
--error-background-color: #{darken($error-red, 16%)}; --error-background-color: #{darken($error-red, 16%)};
--error-active-background-color: #{darken($error-red, 12%)}; --error-active-background-color: #{darken($error-red, 12%)};
--on-error-color: #fff; --on-error-color: #fff;
--rich-text-container-color: rgba(87, 24, 60, 100%);
--rich-text-text-color: rgba(255, 175, 212, 100%);
--rich-text-decorations-color: rgba(128, 58, 95, 100%);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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