1
0
This commit is contained in:
Noa Himesaka 2024-03-30 01:28:12 +09:00
commit d255c9e53e
881 changed files with 19600 additions and 10411 deletions

View File

@ -1,7 +1,9 @@
[production] [production]
defaults defaults
not IE 11 > 0.2%
ios >= 15.6
not dead not dead
not OperaMini all
[development] [development]
supports es6-module supports es6-module

View File

@ -5,7 +5,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": { "features": {
"ghcr.io/devcontainers/features/sshd:1": {}, "ghcr.io/devcontainers/features/sshd:1": {}
}, },
"runServices": ["app", "db", "redis"], "runServices": ["app", "db", "redis"],
@ -15,16 +15,16 @@
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "web", "label": "web",
"onAutoForward": "notify", "onAutoForward": "notify"
}, },
"4000": { "4000": {
"label": "stream", "label": "stream",
"onAutoForward": "silent", "onAutoForward": "silent"
}, }
}, },
"otherPortsAttributes": { "otherPortsAttributes": {
"onAutoForward": "silent", "onAutoForward": "silent"
}, },
"remoteEnv": { "remoteEnv": {
@ -33,7 +33,7 @@
"STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev", "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev",
"DISABLE_FORGERY_REQUEST_PROTECTION": "true", "DISABLE_FORGERY_REQUEST_PROTECTION": "true",
"ES_ENABLED": "", "ES_ENABLED": "",
"LIBRE_TRANSLATE_ENDPOINT": "", "LIBRE_TRANSLATE_ENDPOINT": ""
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
@ -43,7 +43,7 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"], "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
}, }
}, }
} }

View File

@ -5,7 +5,7 @@
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": { "features": {
"ghcr.io/devcontainers/features/sshd:1": {}, "ghcr.io/devcontainers/features/sshd:1": {}
}, },
"forwardPorts": [3000, 4000], "forwardPorts": [3000, 4000],
@ -14,17 +14,17 @@
"3000": { "3000": {
"label": "web", "label": "web",
"onAutoForward": "notify", "onAutoForward": "notify",
"requireLocalPort": true, "requireLocalPort": true
}, },
"4000": { "4000": {
"label": "stream", "label": "stream",
"onAutoForward": "silent", "onAutoForward": "silent",
"requireLocalPort": true, "requireLocalPort": true
}, }
}, },
"otherPortsAttributes": { "otherPortsAttributes": {
"onAutoForward": "silent", "onAutoForward": "silent"
}, },
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
@ -34,7 +34,7 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": {}, "settings": {},
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"], "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
}, }
}, }
} }

View File

@ -70,7 +70,7 @@ services:
hard: -1 hard: -1
libretranslate: libretranslate:
image: libretranslate/libretranslate:v1.5.5 image: libretranslate/libretranslate:v1.5.6
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- lt-data:/home/libretranslate/.local - lt-data:/home/libretranslate/.local

View File

@ -123,7 +123,7 @@ module.exports = defineConfig({
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
'react/self-closing-comp': 'error', 'react/self-closing-comp': 'error',
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/index.js // recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
'jsx-a11y/accessible-emoji': 'warn', 'jsx-a11y/accessible-emoji': 'warn',
'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off', 'jsx-a11y/label-has-associated-control': 'off',
@ -176,7 +176,7 @@ module.exports = defineConfig({
}, },
], ],
// See https://github.com/import-js/eslint-plugin-import/blob/main/config/recommended.js // See https://github.com/import-js/eslint-plugin-import/blob/v2.29.1/config/recommended.js
'import/extensions': [ 'import/extensions': [
'error', 'error',
'always', 'always',
@ -355,7 +355,6 @@ module.exports = defineConfig({
'plugin:import/typescript', 'plugin:import/typescript',
'plugin:promise/recommended', 'plugin:promise/recommended',
'plugin:jsdoc/recommended-typescript', 'plugin:jsdoc/recommended-typescript',
'plugin:prettier/recommended',
], ],
parserOptions: { parserOptions: {
@ -364,6 +363,9 @@ module.exports = defineConfig({
}, },
rules: { rules: {
// Disable formatting rules that have been enabled in the base config
'indent': 'off',
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
'@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'],

4
.github/codecov.yml vendored
View File

@ -1,3 +1,4 @@
comment: false # Do not leave PR comments
coverage: coverage:
status: status:
project: project:
@ -8,6 +9,3 @@ coverage:
default: default:
# Github status check is not blocking # Github status check is not blocking
informational: true informational: true
comment:
# Only write a comment in PR if there are changes
require_changes: true

18
.github/workflows/format-check.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: Check formatting
on:
push:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Check formatting with Prettier
run: yarn format:check

View File

@ -43,4 +43,4 @@ jobs:
- run: echo "::add-matcher::.github/stylelint-matcher.json" - run: echo "::add-matcher::.github/stylelint-matcher.json"
- name: Stylelint - name: Stylelint
run: yarn lint:sass run: yarn lint:css

View File

@ -1,38 +0,0 @@
name: JSON Linting
on:
push:
branches-ignore:
- 'dependabot/**'
- 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
- '.nvmrc'
- '.prettier*'
- '**/*.json'
- '.github/workflows/lint-json.yml'
- '!app/javascript/mastodon/locales/*.json'
pull_request:
paths:
- 'package.json'
- 'yarn.lock'
- '.nvmrc'
- '.prettier*'
- '**/*.json'
- '.github/workflows/lint-json.yml'
- '!app/javascript/mastodon/locales/*.json'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Prettier
run: yarn lint:json

View File

@ -1,38 +0,0 @@
name: Markdown Linting
on:
push:
branches-ignore:
- 'dependabot/**'
- 'renovate/**'
paths:
- '.github/workflows/lint-md.yml'
- '.nvmrc'
- '.prettier*'
- '**/*.md'
- '!AUTHORS.md'
- 'package.json'
- 'yarn.lock'
pull_request:
paths:
- '.github/workflows/lint-md.yml'
- '.nvmrc'
- '.prettier*'
- '**/*.md'
- '!AUTHORS.md'
- 'package.json'
- 'yarn.lock'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Prettier
run: yarn lint:md

View File

@ -1,40 +0,0 @@
name: YML Linting
on:
push:
branches-ignore:
- 'dependabot/**'
- 'renovate/**'
paths:
- 'package.json'
- 'yarn.lock'
- '.nvmrc'
- '.prettier*'
- '**/*.yaml'
- '**/*.yml'
- '.github/workflows/lint-yml.yml'
- '!config/locales/*.yml'
pull_request:
paths:
- 'package.json'
- 'yarn.lock'
- '.nvmrc'
- '.prettier*'
- '**/*.yaml'
- '**/*.yml'
- '.github/workflows/lint-yml.yml'
- '!config/locales/*.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Prettier
run: yarn lint:yml

View File

@ -14,3 +14,5 @@ linters:
enabled: true enabled: true
LineLength: LineLength:
max: 320 max: 320
ViewLength:
max: 200 # Override default value of 100 inherited from rubocop

View File

@ -1,13 +0,0 @@
# This configuration was generated by
# `haml-lint --auto-gen-config`
# on 2024-01-09 11:30:07 -0500 using Haml-Lint version 0.53.0.
# The point is for the user to remove these configuration records
# one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again.
linters:
# Offense count: 1
LineLength:
exclude:
- 'app/views/admin/roles/_form.html.haml'

View File

@ -54,6 +54,13 @@
# Ignore Docker option files # Ignore Docker option files
docker-compose.override.yml docker-compose.override.yml
# Ignore public
/public/assets
/public/emoji
/public/packs
/public/packs-test
/public/system
# Ignore emoji map file # Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json /app/javascript/mastodon/features/emoji/emoji_map.json
@ -74,6 +81,7 @@ app/javascript/styles/mastodon/reset.scss
# Ignore the generated AUTHORS.md # Ignore the generated AUTHORS.md
AUTHORS.md AUTHORS.md
# Process a few selected JS files
!lint-staged.config.js !lint-staged.config.js
# Ignore glitch-soc emoji map file # Ignore glitch-soc emoji map file

View File

@ -20,7 +20,7 @@ FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
# Example: v4.2.0-nightly.2023.11.09+something # Example: v4.2.0-nightly.2023.11.09+something
# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] # Overwrite existence of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"]
ARG MASTODON_VERSION_PRERELEASE="" ARG MASTODON_VERSION_PRERELEASE=""
# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"]
ARG MASTODON_VERSION_METADATA="" ARG MASTODON_VERSION_METADATA=""
@ -29,7 +29,7 @@ ARG MASTODON_VERSION_METADATA=""
# See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files # See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files
ARG RAILS_SERVE_STATIC_FILES="true" ARG RAILS_SERVE_STATIC_FILES="true"
# Allow to use YJIT compiler # Allow to use YJIT compiler
# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md # See: https://github.com/ruby/ruby/blob/v3_2_3/doc/yjit/yjit.md
ARG RUBY_YJIT_ENABLE="1" ARG RUBY_YJIT_ENABLE="1"
# Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] # Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin]
ARG TZ="Etc/UTC" ARG TZ="Etc/UTC"

View File

@ -59,6 +59,7 @@ gem 'http', '~> 5.1'
gem 'http_accept_language', '~> 2.1' gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 1.6.2' gem 'httplog', '~> 1.6.2'
gem 'idn-ruby', require: 'idn' gem 'idn-ruby', require: 'idn'
gem 'inline_svg'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar'
@ -112,7 +113,7 @@ group :test do
# RSpec helpers for email specs # RSpec helpers for email specs
gem 'email_spec' gem 'email_spec'
# Extra RSpec extenion methods and helpers for sidekiq # Extra RSpec extension methods and helpers for sidekiq
gem 'rspec-sidekiq', '~> 4.0' gem 'rspec-sidekiq', '~> 4.0'
# Browser integration testing # Browser integration testing

View File

@ -350,14 +350,17 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
inline_svg (1.9.0)
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2) io-console (0.7.2)
irb (1.11.2) irb (1.12.0)
rdoc rdoc
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.1) json (2.7.1)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.15.3) json-jwt (1.15.3.1)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
bindata bindata
@ -372,7 +375,7 @@ GEM
json-ld-preloaded (3.3.0) json-ld-preloaded (3.3.0)
json-ld (~> 3.3) json-ld (~> 3.3)
rdf (~> 3.3) rdf (~> 3.3)
json-schema (4.1.1) json-schema (4.2.0)
addressable (>= 2.8) addressable (>= 2.8)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.7.1) jwt (2.7.1)
@ -455,7 +458,7 @@ GEM
net-smtp (0.4.0.1) net-smtp (0.4.0.1)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.16.2) nokogiri (1.16.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.3.0) nsa (0.3.0)
@ -465,11 +468,11 @@ GEM
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.16.3) oj (3.16.3)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
omniauth (2.1.1) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-cas (3.0.0.beta.1) omniauth-cas (3.0.0)
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
@ -505,7 +508,7 @@ 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.5) pg (1.5.6)
pghero (3.4.1) pghero (3.4.1)
activerecord (>= 6) activerecord (>= 6)
posix-spawn (0.3.15) posix-spawn (0.3.15)
@ -535,7 +538,7 @@ GEM
rack (2.2.8.1) rack (2.2.8.1)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-cors (2.0.1) rack-cors (2.0.2)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-oauth2 (1.21.3) rack-oauth2 (1.21.3)
activesupport activesupport
@ -543,8 +546,9 @@ GEM
httpclient httpclient
json-jwt (>= 1.11.0) json-jwt (>= 1.11.0)
rack (>= 2.1.0) rack (>= 2.1.0)
rack-protection (3.0.5) rack-protection (3.2.0)
rack base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-session (1.0.2) rack-session (1.0.2)
@ -606,7 +610,7 @@ GEM
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.9.0) regexp_parser (2.9.0)
reline (0.4.2) reline (0.4.3)
io-console (~> 0.5) io-console (~> 0.5)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
@ -621,16 +625,16 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
rqrcode_core (1.2.0) rqrcode_core (1.2.0)
rspec-core (3.12.2) rspec-core (3.13.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-expectations (3.12.3) rspec-expectations (3.13.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.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.12.6) rspec-mocks (3.13.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (6.1.1) rspec-rails (6.1.1)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
@ -644,7 +648,7 @@ GEM
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 8)
rspec-support (3.12.1) rspec-support (3.13.1)
rubocop (1.60.2) rubocop (1.60.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
@ -743,7 +747,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3) unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1) terrapin (1.0.1)
climate_control climate_control
test-prof (1.3.1) test-prof (1.3.2)
thor (1.3.1) thor (1.3.1)
tilt (2.3.0) tilt (2.3.0)
timeout (0.4.1) timeout (0.4.1)
@ -864,6 +868,7 @@ DEPENDENCIES
httplog (~> 1.6.2) httplog (~> 1.6.2)
i18n-tasks (~> 1.0) i18n-tasks (~> 1.0)
idn-ruby idn-ruby
inline_svg
irb (~> 1.8) irb (~> 1.8)
json-ld json-ld
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)

148
README.md
View File

@ -17,3 +17,151 @@ We also recommend applying wide custom emoji CSS for better experience.
# Thanks to... # Thanks to...
- Freeplay for [mastodon-modern](https://codeberg.org/Freeplay/Mastodon-Modern/src/branch/main) theme - Freeplay for [mastodon-modern](https://codeberg.org/Freeplay/Mastodon-Modern/src/branch/main) theme
- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/).
- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/).
Mastodon Glitch-YRYR is a fork of Mastodon Glitch Edition which is a fork of [Mastodon](https://github.com/mastodon/mastodon). Up-Upstream's README file is reproduced below.
---
<h1><picture>
<source media="(prefers-color-scheme: dark)" srcset="./lib/assets/wordmark.dark.png?raw=true">
<source media="(prefers-color-scheme: light)" srcset="./lib/assets/wordmark.light.png?raw=true">
<img alt="Mastodon" src="./lib/assets/wordmark.light.png?raw=true" height="34">
</picture></h1>
[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[releases]: https://github.com/mastodon/mastodon/releases
[crowdin]: https://crowdin.com/project/mastodon
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
Click below to **learn more** in a video:
[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo]
[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE
## Navigation
- [Project homepage 🐘](https://joinmastodon.org)
- [Support the development via Patreon][patreon]
- [View sponsors](https://joinmastodon.org/sponsors)
- [Blog](https://blog.joinmastodon.org)
- [Documentation](https://docs.joinmastodon.org)
- [Roadmap](https://joinmastodon.org/roadmap)
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
- [Browse Mastodon servers](https://joinmastodon.org/communities)
- [Browse Mastodon apps](https://joinmastodon.org/apps)
[patreon]: https://www.patreon.com/mastodon
## Features
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
### No vendor lock-in: Fully interoperable with any conforming platform
It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
### Real-time, chronological timeline updates
Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
### Media attachments like images and short videos
Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
### Safety and moderation tools
Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
### OAuth2 and a straightforward REST API
Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
## Deployment
### Tech stack
- **Ruby on Rails** powers the REST API and other web pages
- **React.js** and Redux are used for the dynamic parts of the interface
- **Node.js** powers the streaming API
### Requirements
- **PostgreSQL** 12+
- **Redis** 4+
- **Ruby** 3.0+
- **Node.js** 16+
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, **Scalingo**, and **Nanobox**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
## Development
### Vagrant
A **Vagrant** configuration is included for development purposes. To use it, complete the following steps:
- Install Vagrant and Virtualbox
- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater`
- Run `vagrant up`
- Run `vagrant ssh -c "cd /vagrant && bin/dev"`
- Open `http://mastodon.local` in your browser
### MacOS
To set up **MacOS** for native development, complete the following steps:
- Use a Ruby version manager to install the specified version from `.ruby-version`
- Run `brew install postgresql@14 redis imagemagick libidn` to install required dependencies
- Navigate to Mastodon's root directory and run `brew install nvm` then `nvm use` to use the version from `.nvmrc`
- Run `corepack enable && corepack prepare`
- Run `bundle exec rails db:setup` (optionally prepend `RAILS_ENV=development` to target the dev environment)
- Finally, run `bin/dev` which will launch the local services via `overmind` (if installed) or `foreman`
### Docker
For development with **Docker**, complete the following steps:
- Install Docker Desktop
- Run `docker compose -f .devcontainer/docker-compose.yml up -d`
- Run `docker compose -f .devcontainer/docker-compose.yml exec app .devcontainer/post-create.sh`
- Finally, run `docker compose -f .devcontainer/docker-compose.yml exec app bin/dev`
If you are using an IDE with [support for the Development Container specification](https://containers.dev/supporting), it will run the above `docker compose` commands automatically. For **Visual Studio Code** this requires the [Dev Container extension](https://containers.dev/supporting#dev-containers).
### GitHub Codespaces
To get you coding in just a few minutes, GitHub Codespaces provides a web-based version of Visual Studio Code and a cloud-hosted development environment fully configured with the software needed for this project..
- Click this button to create a new codespace:<br>
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=52281283&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json)
- Wait for the environment to build. This will take a few minutes.
- When the editor is ready, run `bin/dev` in the terminal.
- After a few seconds, a popup will appear with a button labeled _Open in Browser_. This will open Mastodon.
- On the _Ports_ tab, right click on the “stream” row and select _Port visibility__Public_.
## Contributing
Mastodon is **free, open-source software** licensed under **AGPLv3**.
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
**IRC channel**: #mastodon on irc.libera.chat
## License
Copyright (C) 2016-2024 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
>>>>>>> 3341db939cd077820ad598b0445d02ab2382eaf4

2
Vagrantfile vendored
View File

@ -188,7 +188,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.post_up_message = <<MESSAGE config.vm.post_up_message = <<MESSAGE
To start server To start server
$ vagrant ssh -c "cd /vagrant && foreman start" $ vagrant ssh -c "cd /vagrant && bin/dev"
MESSAGE MESSAGE
end end

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::BaseController < Api::BaseController class ActivityPub::BaseController < Api::BaseController
include SignatureVerification
include AccountOwnedConcern
skip_before_action :require_authenticated_user! skip_before_action :require_authenticated_user!
skip_before_action :require_not_suspended! skip_before_action :require_not_suspended!
skip_around_action :set_locale skip_around_action :set_locale

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::ClaimsController < ActivityPub::BaseController class ActivityPub::ClaimsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
before_action :require_account_signature! before_action :require_account_signature!

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::CollectionsController < ActivityPub::BaseController class ActivityPub::CollectionsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
vary_by -> { 'Signature' if authorized_fetch_mode? } vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :require_account_signature!, if: :authorized_fetch_mode?

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
vary_by -> { 'Signature' if authorized_fetch_mode? } vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature! before_action :require_account_signature!

View File

@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::InboxesController < ActivityPub::BaseController class ActivityPub::InboxesController < ActivityPub::BaseController
include SignatureVerification
include JsonLdHelper include JsonLdHelper
include AccountOwnedConcern
before_action :skip_unknown_actor_activity before_action :skip_unknown_actor_activity
before_action :require_actor_signature! before_action :require_actor_signature!

View File

@ -3,9 +3,6 @@
class ActivityPub::OutboxesController < ActivityPub::BaseController class ActivityPub::OutboxesController < ActivityPub::BaseController
LIMIT = 20 LIMIT = 20
include SignatureVerification
include AccountOwnedConcern
vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? } vary_by -> { 'Signature' if authorized_fetch_mode? || page_requested? }
before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :require_account_signature!, if: :authorized_fetch_mode?

View File

@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::RepliesController < ActivityPub::BaseController class ActivityPub::RepliesController < ActivityPub::BaseController
include SignatureVerification
include Authorization include Authorization
include AccountOwnedConcern
DESCENDANTS_LIMIT = 60 DESCENDANTS_LIMIT = 60

View File

@ -128,7 +128,7 @@ module Admin
def unblock_email def unblock_email
authorize @account, :unblock_email? authorize @account, :unblock_email?
CanonicalEmailBlock.where(reference_account: @account).delete_all CanonicalEmailBlock.matching_account(@account).delete_all
log_action :unblock_email, @account log_action :unblock_email, @account

View File

@ -53,7 +53,7 @@ module Admin
end end
def resource_params def resource_params
params.require(:rule).permit(:text, :priority) params.require(:rule).permit(:text, :hint, :priority)
end end
end end
end end

View File

@ -8,6 +8,7 @@ class Api::BaseController < ApplicationController
include Api::AccessTokenTrackingConcern include Api::AccessTokenTrackingConcern
include Api::CachingConcern include Api::CachingConcern
include Api::ContentSecurityPolicy include Api::ContentSecurityPolicy
include Api::ErrorHandling
skip_before_action :require_functional!, unless: :limited_federation_mode? skip_before_action :require_functional!, unless: :limited_federation_mode?
@ -18,51 +19,6 @@ class Api::BaseController < ApplicationController
protect_from_forgery with: :null_session protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotUnique do
render json: { error: 'Duplicate record' }, status: 422
end
rescue_from Date::Error do
render json: { error: 'Invalid date supplied' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404
end
rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
render json: { error: 'Remote data could not be fetched' }, status: 503
end
rescue_from OpenSSL::SSL::SSLError do
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end
rescue_from Mastodon::NotPermittedError do
render json: { error: 'This action is not allowed' }, status: 403
end
rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RateLimitExceededError do
render json: { error: I18n.t('errors.429') }, status: 429
end
rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e|
render json: { error: e.to_s }, status: 400
end
def doorkeeper_unauthorized_render_options(error: nil) def doorkeeper_unauthorized_render_options(error: nil)
{ json: { error: error.try(:description) || 'Not authorized' } } { json: { error: error.try(:description) || 'Not authorized' } }
end end
@ -73,6 +29,14 @@ class Api::BaseController < ApplicationController
protected protected
def pagination_max_id
pagination_collection.last.id
end
def pagination_since_id
pagination_collection.first.id
end
def set_pagination_headers(next_path = nil, prev_path = nil) def set_pagination_headers(next_path = nil, prev_path = nil)
links = [] links = []
links << [next_path, [%w(rel next)]] if next_path links << [next_path, [%w(rel next)]] if next_path
@ -140,6 +104,10 @@ class Api::BaseController < ApplicationController
private private
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_options_invalid? def pagination_options_invalid?
params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?) params.slice(:limit, :offset).values.map(&:to_i).any?(&:negative?)
end end

View File

@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_account_followers_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_account_followers_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -41,10 +41,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -4,7 +4,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_account before_action :set_account
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) } after_action :insert_pagination_headers
def index def index
cache_if_unauthenticated! cache_if_unauthenticated!
@ -35,10 +35,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params) params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -51,11 +47,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end end
def pagination_max_id def pagination_collection
@statuses.last.id @statuses
end
def pagination_since_id
@statuses.first.id
end end
end end

View File

@ -125,10 +125,6 @@ class Api::V1::Admin::AccountsController < Api::BaseController
translated_params translated_params
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -137,12 +133,8 @@ class Api::V1::Admin::AccountsController < Api::BaseController
api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty? api_v1_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View File

@ -65,10 +65,6 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
@canonical_email_block = CanonicalEmailBlock.find(params[:id]) @canonical_email_block = CanonicalEmailBlock.find(params[:id])
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_canonical_email_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -77,12 +73,8 @@ class Api::V1::Admin::CanonicalEmailBlocksController < Api::BaseController
api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty? api_v1_admin_canonical_email_blocks_url(pagination_params(min_id: pagination_since_id)) unless @canonical_email_blocks.empty?
end end
def pagination_max_id def pagination_collection
@canonical_email_blocks.last.id @canonical_email_blocks
end
def pagination_since_id
@canonical_email_blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -61,10 +61,6 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
DomainAllow.all DomainAllow.all
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_domain_allows_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -73,12 +69,8 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController
api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty? api_v1_admin_domain_allows_url(pagination_params(min_id: pagination_since_id)) unless @domain_allows.empty?
end end
def pagination_max_id def pagination_collection
@domain_allows.last.id @domain_allows
end
def pagination_since_id
@domain_allows.first.id
end end
def records_continue? def records_continue?

View File

@ -72,10 +72,6 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) params.permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -84,12 +80,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty? api_v1_admin_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @domain_blocks.empty?
end end
def pagination_max_id def pagination_collection
@domain_blocks.last.id @domain_blocks
end
def pagination_since_id
@domain_blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -58,10 +58,6 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController
params.permit(:domain, :allow_with_approval) params.permit(:domain, :allow_with_approval)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_email_domain_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -70,12 +66,8 @@ class Api::V1::Admin::EmailDomainBlocksController < Api::BaseController
api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty? api_v1_admin_email_domain_blocks_url(pagination_params(min_id: pagination_since_id)) unless @email_domain_blocks.empty?
end end
def pagination_max_id def pagination_collection
@email_domain_blocks.last.id @email_domain_blocks
end
def pagination_since_id
@email_domain_blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -63,10 +63,6 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController
params.permit(:ip, :severity, :comment, :expires_in) params.permit(:ip, :severity, :comment, :expires_in)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_ip_blocks_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -75,12 +71,8 @@ class Api::V1::Admin::IpBlocksController < Api::BaseController
api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty? api_v1_admin_ip_blocks_url(pagination_params(min_id: pagination_since_id)) unless @ip_blocks.empty?
end end
def pagination_max_id def pagination_collection
@ip_blocks.last.id @ip_blocks
end
def pagination_since_id
@ip_blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -89,10 +89,6 @@ class Api::V1::Admin::ReportsController < Api::BaseController
params.permit(*FILTER_PARAMS) params.permit(*FILTER_PARAMS)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_reports_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -101,12 +97,8 @@ class Api::V1::Admin::ReportsController < Api::BaseController
api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty? api_v1_admin_reports_url(pagination_params(min_id: pagination_since_id)) unless @reports.empty?
end end
def pagination_max_id def pagination_collection
@reports.last.id @reports
end
def pagination_since_id
@reports.first.id
end end
def records_continue? def records_continue?

View File

@ -44,10 +44,6 @@ class Api::V1::Admin::TagsController < Api::BaseController
params.permit(:display_name, :trendable, :usable, :listable) params.permit(:display_name, :trendable, :usable, :listable)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_tags_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -56,12 +52,8 @@ class Api::V1::Admin::TagsController < Api::BaseController
api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty? api_v1_admin_tags_url(pagination_params(min_id: pagination_since_id)) unless @tags.empty?
end end
def pagination_max_id def pagination_collection
@tags.last.id @tags
end
def pagination_since_id
@tags.first.id
end end
def records_continue? def records_continue?

View File

@ -42,10 +42,6 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC
@providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) @providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue? api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end end
@ -54,12 +50,8 @@ class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseC
api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty? api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty?
end end
def pagination_max_id def pagination_collection
@providers.last.id @providers
end
def pagination_since_id
@providers.first.id
end end
def records_continue? def records_continue?

View File

@ -28,10 +28,6 @@ class Api::V1::BlocksController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_blocks_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -40,12 +36,8 @@ class Api::V1::BlocksController < Api::BaseController
api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty? api_v1_blocks_url pagination_params(since_id: pagination_since_id) unless paginated_blocks.empty?
end end
def pagination_max_id def pagination_collection
paginated_blocks.last.id paginated_blocks
end
def pagination_since_id
paginated_blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -31,10 +31,6 @@ class Api::V1::BookmarksController < Api::BaseController
current_account.bookmarks current_account.bookmarks
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_bookmarks_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -43,12 +39,8 @@ class Api::V1::BookmarksController < Api::BaseController
api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty? api_v1_bookmarks_url pagination_params(min_id: pagination_since_id) unless results.empty?
end end
def pagination_max_id def pagination_collection
results.last.id results
end
def pagination_since_id
results.first.id
end end
def records_continue? def records_continue?

View File

@ -53,10 +53,6 @@ class Api::V1::ConversationsController < Api::BaseController
.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_conversations_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_conversations_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -29,10 +29,6 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
@encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -41,12 +37,8 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty? api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end end
def pagination_max_id def pagination_collection
@encrypted_messages.last.id @encrypted_messages
end
def pagination_since_id
@encrypted_messages.first.id
end end
def records_continue? def records_continue?

View File

@ -38,10 +38,6 @@ class Api::V1::DomainBlocksController < Api::BaseController
current_account.domain_blocks current_account.domain_blocks
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -50,12 +46,8 @@ class Api::V1::DomainBlocksController < Api::BaseController
api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty? api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) unless @blocks.empty?
end end
def pagination_max_id def pagination_collection
@blocks.last.id @blocks
end
def pagination_since_id
@blocks.first.id
end end
def records_continue? def records_continue?

View File

@ -28,10 +28,6 @@ class Api::V1::EndorsementsController < Api::BaseController
current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended current_account.endorsed_accounts.includes(:account_stat, :user).without_suspended
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
return if unlimited? return if unlimited?
@ -44,12 +40,8 @@ class Api::V1::EndorsementsController < Api::BaseController
api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? api_v1_endorsements_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View File

@ -31,10 +31,6 @@ class Api::V1::FavouritesController < Api::BaseController
current_account.favourites current_account.favourites
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_favourites_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_favourites_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -43,12 +39,8 @@ class Api::V1::FavouritesController < Api::BaseController
api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty? api_v1_favourites_url pagination_params(min_id: pagination_since_id) unless results.empty?
end end
def pagination_max_id def pagination_collection
results.last.id results
end
def pagination_since_id
results.first.id
end end
def records_continue? def records_continue?

View File

@ -48,10 +48,6 @@ class Api::V1::FollowRequestsController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -22,10 +22,6 @@ class Api::V1::FollowedTagsController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -34,12 +30,8 @@ class Api::V1::FollowedTagsController < Api::BaseController
api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty?
end end
def pagination_max_id def pagination_collection
@results.last.id @results
end
def pagination_since_id
@results.first.id
end end
def records_continue? def records_continue?

View File

@ -55,10 +55,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController
params.permit(account_ids: []) params.permit(account_ids: [])
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
return if unlimited? return if unlimited?
@ -71,12 +67,8 @@ class Api::V1::Lists::AccountsController < Api::BaseController
api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end end
def pagination_max_id def pagination_collection
@accounts.last.id @accounts
end
def pagination_since_id
@accounts.first.id
end end
def records_continue? def records_continue?

View File

@ -28,10 +28,6 @@ class Api::V1::MutesController < Api::BaseController
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_mutes_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_mutes_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -40,12 +36,8 @@ class Api::V1::MutesController < Api::BaseController
api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty? api_v1_mutes_url pagination_params(since_id: pagination_since_id) unless paginated_mutes.empty?
end end
def pagination_max_id def pagination_collection
paginated_mutes.last.id paginated_mutes
end
def pagination_since_id
paginated_mutes.first.id
end end
def records_continue? def records_continue?

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
class Api::V1::Notifications::PoliciesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update
before_action :require_user!
before_action :set_policy
def show
render json: @policy, serializer: REST::NotificationPolicySerializer
end
def update
@policy.update!(resource_params)
render json: @policy, serializer: REST::NotificationPolicySerializer
end
private
def set_policy
@policy = NotificationPolicy.find_or_initialize_by(account: current_account)
with_read_replica do
@policy.summarize!
end
end
def resource_params
params.permit(
:filter_not_following,
:filter_not_followers,
:filter_new_accounts,
:filter_private_mentions
)
end
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
class Api::V1::Notifications::RequestsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index
before_action :require_user!
before_action :set_request, except: :index
after_action :insert_pagination_headers, only: :index
def index
with_read_replica do
@requests = load_requests
@relationships = relationships
end
render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships
end
def show
render json: @request, serializer: REST::NotificationRequestSerializer
end
def accept
AcceptNotificationRequestService.new.call(@request)
render_empty
end
def dismiss
@request.update!(dismissed: true)
render_empty
end
private
def load_requests
requests = NotificationRequest.where(account: current_account).where(dismissed: truthy_param?(:dismissed) || false).includes(:last_status, from_account: [:account_stat, :user]).to_a_paginated_by_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
NotificationRequest.preload_cache_collection(requests) do |statuses|
cache_collection(statuses, Status)
end
end
def relationships
StatusRelationshipsPresenter.new(@requests.map(&:last_status), current_user&.account_id)
end
def set_request
@request = NotificationRequest.where(account: current_account).find(params[:id])
end
def next_path
api_v1_notifications_requests_url pagination_params(max_id: pagination_max_id) unless @requests.empty?
end
def prev_path
api_v1_notifications_requests_url pagination_params(min_id: pagination_since_id) unless @requests.empty?
end
def pagination_max_id
@requests.last.id
end
def pagination_since_id
@requests.first.id
end
def pagination_params(core_params)
params.slice(:dismissed).permit(:dismissed).merge(core_params)
end
end

View File

@ -58,7 +58,8 @@ class Api::V1::NotificationsController < Api::BaseController
current_account.notifications.without_suspended.browserable( current_account.notifications.without_suspended.browserable(
types: Array(browserable_params[:types]), types: Array(browserable_params[:types]),
exclude_types: Array(browserable_params[:exclude_types]), exclude_types: Array(browserable_params[:exclude_types]),
from_account_id: browserable_params[:account_id] from_account_id: browserable_params[:account_id],
include_filtered: truthy_param?(:include_filtered)
) )
end end
@ -66,10 +67,6 @@ class Api::V1::NotificationsController < Api::BaseController
@notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty? api_v1_notifications_url pagination_params(max_id: pagination_max_id) unless @notifications.empty?
end end
@ -78,19 +75,15 @@ class Api::V1::NotificationsController < Api::BaseController
api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty? api_v1_notifications_url pagination_params(min_id: pagination_since_id) unless @notifications.empty?
end end
def pagination_max_id def pagination_collection
@notifications.last.id @notifications
end
def pagination_since_id
@notifications.first.id
end end
def browserable_params def browserable_params
params.permit(:account_id, types: [], exclude_types: []) params.permit(:account_id, :include_filtered, types: [], exclude_types: [])
end end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit, :account_id, :types, :exclude_types).permit(:limit, :account_id, types: [], exclude_types: []).merge(core_params) params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params)
end end
end end

View File

@ -47,10 +47,6 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
params.slice(:limit).permit(:limit).merge(core_params) params.slice(:limit).permit(:limit).merge(core_params)
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_scheduled_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
end end
@ -63,11 +59,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
end end
def pagination_max_id def pagination_collection
@statuses.last.id @statuses
end
def pagination_since_id
@statuses.first.id
end end
end end

View File

@ -34,10 +34,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::V1::Statuses::Bas
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -30,10 +30,6 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::V1::Statuses::Base
) )
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path def next_path
api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end end

View File

@ -5,16 +5,8 @@ class Api::V1::Timelines::BaseController < Api::BaseController
private private
def insert_pagination_headers def pagination_collection
set_pagination_headers(next_path, prev_path) @statuses
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end end
def next_path_params def next_path_params

View File

@ -34,10 +34,6 @@ class Api::V1::Trends::LinksController < Api::BaseController
scope scope
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params) params.slice(:limit).permit(:limit).merge(core_params)
end end

View File

@ -32,10 +32,6 @@ class Api::V1::Trends::StatusesController < Api::BaseController
scope scope
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params) params.slice(:limit).permit(:limit).merge(core_params)
end end

View File

@ -30,10 +30,6 @@ class Api::V1::Trends::TagsController < Api::BaseController
Trends.tags.query.allowed Trends.tags.query.allowed
end end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params) def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params) params.slice(:limit).permit(:limit).merge(core_params)
end end

View File

@ -131,7 +131,7 @@ class ApplicationController < ActionController::Base
end end
def single_user_mode? def single_user_mode?
@single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.without_internal.exists?
end end
def use_seamless_external_login? def use_seamless_external_login?

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
module Api::ErrorHandling
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
end
rescue_from ActiveRecord::RecordNotUnique do
render json: { error: 'Duplicate record' }, status: 422
end
rescue_from Date::Error do
render json: { error: 'Invalid date supplied' }, status: 422
end
rescue_from ActiveRecord::RecordNotFound do
render json: { error: 'Record not found' }, status: 404
end
rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do
render json: { error: 'Remote data could not be fetched' }, status: 503
end
rescue_from OpenSSL::SSL::SSLError do
render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
end
rescue_from Mastodon::NotPermittedError do
render json: { error: 'This action is not allowed' }, status: 403
end
rescue_from Seahorse::Client::NetworkingError do |e|
Rails.logger.warn "Storage server error: #{e}"
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RaceConditionError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end
rescue_from Mastodon::RateLimitExceededError do
render json: { error: I18n.t('errors.429') }, status: 429
end
rescue_from ActionController::ParameterMissing, Mastodon::InvalidParameterError do |e|
render json: { error: e.to_s }, status: 400
end
end
end

View File

@ -6,6 +6,8 @@ class InstanceActorsController < ActivityPub::BaseController
serialization_scope nil serialization_scope nil
before_action :set_account before_action :set_account
skip_before_action :authenticate_user! # From `AccountOwnedConcern`
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in skip_before_action :update_user_sign_in
@ -16,6 +18,11 @@ class InstanceActorsController < ActivityPub::BaseController
private private
# Skips various `before_action` from `AccountOwnedConcern`
def account_required?
false
end
def set_account def set_account
@account = Account.representative @account = Account.representative
end end

View File

@ -28,14 +28,6 @@ module ApplicationHelper
number_to_human(number, **options) number_to_human(number, **options)
end end
def active_nav_class(*paths)
paths.any? { |path| current_page?(path) } ? 'active' : ''
end
def show_landing_strip?
!user_signed_in? && !single_user_mode?
end
def open_registrations? def open_registrations?
Setting.registrations_mode == 'open' Setting.registrations_mode == 'open'
end end
@ -122,7 +114,7 @@ module ApplicationHelper
end end
def check_icon def check_icon
content_tag(:svg, tag.path('fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor') inline_svg_tag 'check.svg'
end end
def visibility_icon(status) def visibility_icon(status)
@ -214,7 +206,7 @@ module ApplicationHelper
state_params[:moved_to_account] = current_account.moved_to_account state_params[:moved_to_account] = current_account.moved_to_account
end end
state_params[:owner] = Account.local.without_suspended.where('id > 0').first if single_user_mode? state_params[:owner] = Account.local.without_suspended.without_internal.first if single_user_mode?
json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json
# rubocop:disable Rails/OutputSafety # rubocop:disable Rails/OutputSafety

View File

@ -21,15 +21,4 @@ module BrandingHelper
def render_logo def render_logo
image_pack_tag('logo.svg', alt: 'Mastodon', class: 'logo logo--icon') image_pack_tag('logo.svg', alt: 'Mastodon', class: 'logo logo--icon')
end end
def render_symbol(version = :icon)
path = case version
when :icon
'logo-symbol-icon.svg'
when :wordmark
'logo-symbol-wordmark.svg'
end
render(file: Rails.root.join('app', 'javascript', 'images', path)).html_safe # rubocop:disable Rails/OutputSafety
end
end end

View File

@ -25,12 +25,21 @@ module ContextHelper
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' }, memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' }, voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
olm: { olm: {
'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'toot' => 'http://joinmastodon.org/ns#',
'Device' => 'toot:Device',
'Ed25519Signature' => 'toot:Ed25519Signature',
'Ed25519Key' => 'toot:Ed25519Key',
'Curve25519Key' => 'toot:Curve25519Key',
'EncryptedMessage' => 'toot:EncryptedMessage',
'publicKeyBase64' => 'toot:publicKeyBase64',
'deviceId' => 'toot:deviceId',
'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'claim' => { '@type' => '@id', '@id' => 'toot:claim' },
'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' },
'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' },
'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' },
'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' 'messageFranking' => 'toot:messageFranking',
'messageType' => 'toot:messageType',
'cipherText' => 'toot:cipherText',
}, },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
}.freeze }.freeze

View File

@ -109,6 +109,7 @@ module LanguagesHelper
mn: ['Mongolian', 'Монгол хэл'].freeze, mn: ['Mongolian', 'Монгол хэл'].freeze,
mr: ['Marathi', 'मराठी'].freeze, mr: ['Marathi', 'मराठी'].freeze,
ms: ['Malay', 'Bahasa Melayu'].freeze, ms: ['Malay', 'Bahasa Melayu'].freeze,
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
mt: ['Maltese', 'Malti'].freeze, mt: ['Maltese', 'Malti'].freeze,
my: ['Burmese', 'ဗမာစာ'].freeze, my: ['Burmese', 'ဗမာစာ'].freeze,
na: ['Nauru', 'Ekakairũ Naoero'].freeze, na: ['Nauru', 'Ekakairũ Naoero'].freeze,
@ -127,7 +128,7 @@ module LanguagesHelper
om: ['Oromo', 'Afaan Oromoo'].freeze, om: ['Oromo', 'Afaan Oromoo'].freeze,
or: ['Oriya', 'ଓଡ଼ିଆ'].freeze, or: ['Oriya', 'ଓଡ଼ିଆ'].freeze,
os: ['Ossetian', 'ирон æвзаг'].freeze, os: ['Ossetian', 'ирон æвзаг'].freeze,
pa: ['Panjabi', 'ਪੰਜਾਬੀ'].freeze, pa: ['Punjabi', 'ਪੰਜਾਬੀ'].freeze,
pi: ['Pāli', 'पाऴि'].freeze, pi: ['Pāli', 'पाऴि'].freeze,
pl: ['Polish', 'Polski'].freeze, pl: ['Polish', 'Polski'].freeze,
ps: ['Pashto', 'پښتو'].freeze, ps: ['Pashto', 'پښتو'].freeze,
@ -191,15 +192,20 @@ module LanguagesHelper
chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze, chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze,
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze, ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
cnr: ['Montenegrin', 'crnogorski'].freeze, cnr: ['Montenegrin', 'crnogorski'].freeze,
csb: ['Kashubian', 'Kaszëbsczi'].freeze,
jbo: ['Lojban', 'la .lojban.'].freeze, jbo: ['Lojban', 'la .lojban.'].freeze,
kab: ['Kabyle', 'Taqbaylit'].freeze, kab: ['Kabyle', 'Taqbaylit'].freeze,
ldn: ['Láadan', 'Láadan'].freeze, ldn: ['Láadan', 'Láadan'].freeze,
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze, lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
moh: ['Mohawk', 'Kanienʼkéha'].freeze,
nds: ['Low German', 'Plattdüütsch'].freeze,
pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze,
sco: ['Scots', 'Scots'].freeze, sco: ['Scots', 'Scots'].freeze,
sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze, sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,
smj: ['Lule Sami', 'Julevsámegiella'].freeze, smj: ['Lule Sami', 'Julevsámegiella'].freeze,
szl: ['Silesian', 'ślůnsko godka'].freeze, szl: ['Silesian', 'ślůnsko godka'].freeze,
tok: ['Toki Pona', 'toki pona'].freeze, tok: ['Toki Pona', 'toki pona'].freeze,
vai: ['Vai', 'ꕙꔤ'].freeze,
xal: ['Kalmyk', 'Хальмг келн'].freeze, xal: ['Kalmyk', 'Хальмг келн'].freeze,
zba: ['Balaibalan', 'باليبلن'].freeze, zba: ['Balaibalan', 'باليبلن'].freeze,
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,

View File

@ -4,14 +4,6 @@ module StatusesHelper
EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed' EMBEDDED_ACTION = 'embed'
def link_to_newer(url)
link_to t('statuses.show_newer'), url, class: 'load-more load-gap'
end
def link_to_older(url)
link_to t('statuses.show_older'), url, class: 'load-more load-gap'
end
def nothing_here(extra_classes = '') def nothing_here(extra_classes = '')
content_tag(:div, class: "nothing-here #{extra_classes}") do content_tag(:div, class: "nothing-here #{extra_classes}") do
t('accounts.nothing_here') t('accounts.nothing_here')

View File

@ -1,220 +0,0 @@
// This file will be loaded on admin pages, regardless of theme.
import 'packs/public-path';
import Rails from '@rails/ujs';
import ready from '../mastodon/ready';
const setAnnouncementEndsAttributes = (target) => {
const valid = target?.value && target?.validity?.valid;
const element = document.querySelector('input[type="datetime-local"]#announcement_ends_at');
if (valid) {
element.classList.remove('optional');
element.required = true;
element.min = target.value;
} else {
element.classList.add('optional');
element.removeAttribute('required');
element.removeAttribute('min');
}
};
Rails.delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
setAnnouncementEndsAttributes(target);
});
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
const showSelectAll = () => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
selectAllMatchingElement.classList.add('active');
};
const hideSelectAll = () => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
const hiddenField = document.querySelector('#select_all_matching');
const selectedMsg = document.querySelector('.batch-table__select-all .selected');
const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
selectAllMatchingElement.classList.remove('active');
selectedMsg.classList.remove('active');
notSelectedMsg.classList.add('active');
hiddenField.value = '0';
};
Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
[].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
content.checked = target.checked;
});
if (selectAllMatchingElement) {
if (target.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
});
Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
const hiddenField = document.querySelector('#select_all_matching');
const active = hiddenField.value === '1';
const selectedMsg = document.querySelector('.batch-table__select-all .selected');
const notSelectedMsg = document.querySelector('.batch-table__select-all .not-selected');
if (active) {
hiddenField.value = '0';
selectedMsg.classList.remove('active');
notSelectedMsg.classList.add('active');
} else {
hiddenField.value = '1';
notSelectedMsg.classList.remove('active');
selectedMsg.classList.add('active');
}
});
Rails.delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector('#batch_checkbox_all');
const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
if (checkAllElement) {
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
if (selectAllMatchingElement) {
if (checkAllElement.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
}
});
Rails.delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
target.form.submit();
});
const onDomainBlockSeverityChange = (target) => {
const rejectMediaDiv = document.querySelector('.input.with_label.domain_block_reject_media');
const rejectReportsDiv = document.querySelector('.input.with_label.domain_block_reject_reports');
if (rejectMediaDiv) {
rejectMediaDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
}
if (rejectReportsDiv) {
rejectReportsDiv.style.display = (target.value === 'suspend') ? 'none' : 'block';
}
};
Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
const onEnableBootstrapTimelineAccountsChange = (target) => {
const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
if (bootstrapTimelineAccountsField) {
bootstrapTimelineAccountsField.disabled = !target.checked;
if (target.checked) {
bootstrapTimelineAccountsField.parentElement.classList.remove('disabled');
bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled');
} else {
bootstrapTimelineAccountsField.parentElement.classList.add('disabled');
bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled');
}
}
};
Rails.delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
const onChangeRegistrationMode = (target) => {
const enabled = target.value === 'approved';
[].forEach.call(document.querySelectorAll('.form_admin_settings_registrations_mode .warning-hint'), (warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
[].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => {
input.disabled = !enabled;
if (enabled) {
let element = input;
do {
element.classList.remove('disabled');
element = element.parentElement;
} while (element && !element.classList.contains('fields-group'));
} else {
let element = input;
do {
element.classList.add('disabled');
element = element.parentElement;
} while (element && !element.classList.contains('fields-group'));
}
});
};
const convertUTCDateTimeToLocal = (value) => {
const date = new Date(value + 'Z');
const twoChars = (x) => (x.toString().padStart(2, '0'));
return `${date.getFullYear()}-${twoChars(date.getMonth()+1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
};
const convertLocalDatetimeToUTC = (value) => {
const re = /^([0-9]{4,})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})/;
const match = re.exec(value);
const date = new Date(match[1], match[2] - 1, match[3], match[4], match[5]);
const fullISO8601 = date.toISOString();
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
};
Rails.delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
ready(() => {
const domainBlockSeverityInput = document.getElementById('domain_block_severity');
if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput);
const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts');
if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
const registrationMode = document.getElementById('form_admin_settings_registrations_mode');
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector('#batch_checkbox_all');
if (checkAllElement) {
checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
}
document.querySelector('a#add-instance-button')?.addEventListener('click', (e) => {
const domain = document.querySelector('input[type="text"]#by_domain')?.value;
if (domain) {
const url = new URL(event.target.href);
url.searchParams.set('_domain', domain);
e.target.href = url;
}
});
[].forEach.call(document.querySelectorAll('input[type="datetime-local"]'), element => {
if (element.value) {
element.value = convertUTCDateTimeToLocal(element.value);
}
if (element.placeholder) {
element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
}
});
Rails.delegate(document, 'form', 'submit', ({ target }) => {
[].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector('input[type="datetime-local"]#announcement_starts_at');
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
});

View File

@ -0,0 +1,340 @@
// This file will be loaded on admin pages, regardless of theme.
import 'packs/public-path';
import Rails from '@rails/ujs';
import ready from '../mastodon/ready';
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
const valid = target.value && target.validity.valid;
const element = document.querySelector<HTMLInputElement>(
'input[type="datetime-local"]#announcement_ends_at',
);
if (!element) return;
if (valid) {
element.classList.remove('optional');
element.required = true;
element.min = target.value;
} else {
element.classList.add('optional');
element.removeAttribute('required');
element.removeAttribute('min');
}
};
Rails.delegate(
document,
'input[type="datetime-local"]#announcement_starts_at',
'change',
({ target }) => {
if (target instanceof HTMLInputElement)
setAnnouncementEndsAttributes(target);
},
);
const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]';
const showSelectAll = () => {
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
selectAllMatchingElement?.classList.add('active');
};
const hideSelectAll = () => {
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
const hiddenField = document.querySelector<HTMLInputElement>(
'input#select_all_matching',
);
const selectedMsg = document.querySelector(
'.batch-table__select-all .selected',
);
const notSelectedMsg = document.querySelector(
'.batch-table__select-all .not-selected',
);
selectAllMatchingElement?.classList.remove('active');
selectedMsg?.classList.remove('active');
notSelectedMsg?.classList.add('active');
if (hiddenField) hiddenField.value = '0';
};
Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
document
.querySelectorAll<HTMLInputElement>(batchCheckboxClassName)
.forEach((content) => {
content.checked = target.checked;
});
if (selectAllMatchingElement) {
if (target.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
});
Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
const hiddenField = document.querySelector<HTMLInputElement>(
'#select_all_matching',
);
if (!hiddenField) return;
const active = hiddenField.value === '1';
const selectedMsg = document.querySelector(
'.batch-table__select-all .selected',
);
const notSelectedMsg = document.querySelector(
'.batch-table__select-all .not-selected',
);
if (!selectedMsg || !notSelectedMsg) return;
if (active) {
hiddenField.value = '0';
selectedMsg.classList.remove('active');
notSelectedMsg.classList.add('active');
} else {
hiddenField.value = '1';
notSelectedMsg.classList.remove('active');
selectedMsg.classList.add('active');
}
});
Rails.delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
const selectAllMatchingElement = document.querySelector(
'.batch-table__select-all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
);
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
checkAllElement.indeterminate =
!checkAllElement.checked &&
allCheckboxes.some((content) => content.checked);
if (selectAllMatchingElement) {
if (checkAllElement.checked) {
showSelectAll();
} else {
hideSelectAll();
}
}
}
});
Rails.delegate(
document,
'.filter-subset--with-select select',
'change',
({ target }) => {
if (target instanceof HTMLSelectElement) target.form?.submit();
},
);
const onDomainBlockSeverityChange = (target: HTMLSelectElement) => {
const rejectMediaDiv = document.querySelector(
'.input.with_label.domain_block_reject_media',
);
const rejectReportsDiv = document.querySelector(
'.input.with_label.domain_block_reject_reports',
);
if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) {
rejectMediaDiv.style.display =
target.value === 'suspend' ? 'none' : 'block';
}
if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) {
rejectReportsDiv.style.display =
target.value === 'suspend' ? 'none' : 'block';
}
};
Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => {
if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target);
});
const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => {
const bootstrapTimelineAccountsField =
document.querySelector<HTMLInputElement>(
'#form_admin_settings_bootstrap_timeline_accounts',
);
if (bootstrapTimelineAccountsField) {
bootstrapTimelineAccountsField.disabled = !target.checked;
if (target.checked) {
bootstrapTimelineAccountsField.parentElement?.classList.remove(
'disabled',
);
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove(
'disabled',
);
} else {
bootstrapTimelineAccountsField.parentElement?.classList.add('disabled');
bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add(
'disabled',
);
}
}
};
Rails.delegate(
document,
'#form_admin_settings_enable_bootstrap_timeline_accounts',
'change',
({ target }) => {
if (target instanceof HTMLInputElement)
onEnableBootstrapTimelineAccountsChange(target);
},
);
const onChangeRegistrationMode = (target: HTMLSelectElement) => {
const enabled = target.value === 'approved';
document
.querySelectorAll<HTMLElement>(
'.form_admin_settings_registrations_mode .warning-hint',
)
.forEach((warning_hint) => {
warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
});
document
.querySelectorAll<HTMLInputElement>(
'input#form_admin_settings_require_invite_text',
)
.forEach((input) => {
input.disabled = !enabled;
if (enabled) {
let element: HTMLElement | null = input;
do {
element.classList.remove('disabled');
element = element.parentElement;
} while (element && !element.classList.contains('fields-group'));
} else {
let element: HTMLElement | null = input;
do {
element.classList.add('disabled');
element = element.parentElement;
} while (element && !element.classList.contains('fields-group'));
}
});
};
const convertUTCDateTimeToLocal = (value: string) => {
const date = new Date(value + 'Z');
const twoChars = (x: number) => x.toString().padStart(2, '0');
return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`;
};
function convertLocalDatetimeToUTC(value: string) {
const date = new Date(value);
const fullISO8601 = date.toISOString();
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
}
Rails.delegate(
document,
'#form_admin_settings_registrations_mode',
'change',
({ target }) => {
if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target);
},
);
ready(() => {
const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
'select#domain_block_severity',
);
if (domainBlockSeveritySelect)
onDomainBlockSeverityChange(domainBlockSeveritySelect);
const enableBootstrapTimelineAccounts =
document.querySelector<HTMLInputElement>(
'input#form_admin_settings_enable_bootstrap_timeline_accounts',
);
if (enableBootstrapTimelineAccounts)
onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
const registrationMode = document.querySelector<HTMLSelectElement>(
'select#form_admin_settings_registrations_mode',
);
if (registrationMode) onChangeRegistrationMode(registrationMode);
const checkAllElement = document.querySelector<HTMLInputElement>(
'input#batch_checkbox_all',
);
if (checkAllElement) {
const allCheckboxes = Array.from(
document.querySelectorAll<HTMLInputElement>(batchCheckboxClassName),
);
checkAllElement.checked = allCheckboxes.every((content) => content.checked);
checkAllElement.indeterminate =
!checkAllElement.checked &&
allCheckboxes.some((content) => content.checked);
}
document
.querySelector('a#add-instance-button')
?.addEventListener('click', (e) => {
const domain = document.querySelector<HTMLInputElement>(
'input[type="text"]#by_domain',
)?.value;
if (domain && e.target instanceof HTMLAnchorElement) {
const url = new URL(e.target.href);
url.searchParams.set('_domain', domain);
e.target.href = url.toString();
}
});
document
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
.forEach((element) => {
if (element.value) {
element.value = convertUTCDateTimeToLocal(element.value);
}
if (element.placeholder) {
element.placeholder = convertUTCDateTimeToLocal(element.placeholder);
}
});
Rails.delegate(document, 'form', 'submit', ({ target }) => {
if (target instanceof HTMLFormElement)
target
.querySelectorAll<HTMLInputElement>('input[type="datetime-local"]')
.forEach((element) => {
if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value);
}
});
});
const announcementStartsAt = document.querySelector<HTMLInputElement>(
'input[type="datetime-local"]#announcement_starts_at',
);
if (announcementStartsAt) {
setAnnouncementEndsAttributes(announcementStartsAt);
}
}).catch((reason) => {
throw reason;
});

View File

@ -1,25 +0,0 @@
// This file will be loaded on embed pages, regardless of theme.
import 'packs/public-path';
window.addEventListener('message', e => {
const data = e.data || {};
if (!window.parent || data.type !== 'setHeight') {
return;
}
function setEmbedHeight () {
window.parent.postMessage({
type: 'setHeight',
id: data.id,
height: document.getElementsByTagName('html')[0].scrollHeight,
}, '*');
}
if (['interactive', 'complete'].includes(document.readyState)) {
setEmbedHeight();
} else {
document.addEventListener('DOMContentLoaded', setEmbedHeight);
}
});

View File

@ -0,0 +1,41 @@
// This file will be loaded on embed pages, regardless of theme.
import 'packs/public-path';
import ready from '../mastodon/ready';
interface SetHeightMessage {
type: 'setHeight';
id: string;
height: number;
}
function isSetHeightMessage(data: unknown): data is SetHeightMessage {
if (
data &&
typeof data === 'object' &&
'type' in data &&
data.type === 'setHeight'
)
return true;
else return false;
}
window.addEventListener('message', (e) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases
if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return;
const data = e.data;
ready(() => {
window.parent.postMessage(
{
type: 'setHeight',
id: data.id,
height: document.getElementsByTagName('html')[0].scrollHeight,
},
'*',
);
}).catch((e) => {
console.error('Error in setHeightMessage postMessage', e);
});
});

View File

@ -1,44 +0,0 @@
// This file will be loaded on settings pages, regardless of theme.
import 'packs/public-path';
import Rails from '@rails/ujs';
Rails.delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
const avatar = document.getElementById(target.id + '-preview');
const [file] = target.files || [];
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
avatar.src = url;
});
Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
target.focus();
target.select();
target.setSelectionRange(0, target.value.length);
});
Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
const input = target.parentNode.querySelector('.input-copy__wrapper input');
const oldReadOnly = input.readonly;
input.readonly = false;
input.focus();
input.select();
input.setSelectionRange(0, input.value.length);
try {
if (document.execCommand('copy')) {
input.blur();
target.parentNode.classList.add('copied');
setTimeout(() => {
target.parentNode.classList.remove('copied');
}, 700);
}
} catch (err) {
console.error(err);
}
input.readonly = oldReadOnly;
});

View File

@ -0,0 +1,70 @@
// This file will be loaded on settings pages, regardless of theme.
import 'packs/public-path';
import Rails from '@rails/ujs';
Rails.delegate(
document,
'#edit_profile input[type=file]',
'change',
({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
const avatar = document.querySelector<HTMLImageElement>(
`img#${target.id}-preview`,
);
if (!avatar) return;
let file: File | undefined;
if (target.files) file = target.files[0];
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
if (url) avatar.src = url;
},
);
Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
if (!(target instanceof HTMLInputElement)) return;
target.focus();
target.select();
target.setSelectionRange(0, target.value.length);
});
Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
if (!(target instanceof HTMLButtonElement)) return;
const input = target.parentNode?.querySelector<HTMLInputElement>(
'.input-copy__wrapper input',
);
if (!input) return;
const oldReadOnly = input.readOnly;
input.readOnly = false;
input.focus();
input.select();
input.setSelectionRange(0, input.value.length);
try {
if (document.execCommand('copy')) {
input.blur();
const parent = target.parentElement;
if (!parent) return;
parent.classList.add('copied');
setTimeout(() => {
parent.classList.remove('copied');
}, 700);
}
} catch (err) {
console.error(err);
}
input.readOnly = oldReadOnly;
});

View File

@ -2,12 +2,12 @@
# theme. # theme.
pack: pack:
about: about:
admin: admin.js admin: admin.ts
auth: auth.js auth: auth.js
common: common:
filename: common.js filename: common.js
stylesheet: true stylesheet: true
embed: embed.js embed: embed.ts
error: error:
home: home:
inert: inert:
@ -18,7 +18,7 @@ pack:
stylesheet: true stylesheet: true
modal: modal:
public: public:
settings: settings.js settings: settings.ts
sign_up: sign_up:
share: share:
remote_interaction_helper: remote_interaction_helper.ts remote_interaction_helper: remote_interaction_helper.ts

View File

@ -66,11 +66,9 @@ export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
@ -93,11 +91,6 @@ export * from './accounts_typed';
export function fetchAccount(id) { export function fetchAccount(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchRelationships([id])); dispatch(fetchRelationships([id]));
if (getState().getIn(['accounts', id], null) !== null) {
return;
}
dispatch(fetchAccountRequest(id)); dispatch(fetchAccountRequest(id));
api(getState).get(`/api/v1/accounts/${id}`).then(response => { api(getState).get(`/api/v1/accounts/${id}`).then(response => {

View File

@ -12,8 +12,6 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL';
export function fetchBlocks() { export function fetchBlocks() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchBlocksRequest()); dispatch(fetchBlocksRequest());
@ -90,11 +88,12 @@ export function expandBlocksFail(error) {
export function initBlockModal(account) { export function initBlockModal(account) {
return dispatch => { return dispatch => {
dispatch({ dispatch(openModal({
type: BLOCKS_INIT_MODAL, modalType: 'BLOCK',
account, modalProps: {
}); accountId: account.get('id'),
acct: account.get('acct'),
dispatch(openModal({ modalType: 'BLOCK' })); },
}));
}; };
} }

View File

@ -266,12 +266,14 @@ export function submitCompose(routerHistory, overridePrivacy = null) {
insertIfOnline('direct'); insertIfOnline('direct');
} }
dispatch(showAlert({ if (getState().getIn(['local_settings', 'show_published_toast'])) {
message: statusId === null ? messages.published : messages.saved, dispatch(showAlert({
action: messages.open, message: statusId === null ? messages.published : messages.saved,
dismissAfter: 10000, action: messages.open,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), dismissAfter: 10000,
})); onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
}); });
@ -820,11 +822,12 @@ export function addPollOption(title) {
}; };
} }
export function changePollOption(index, title) { export function changePollOption(index, title, maxOptions) {
return { return {
type: COMPOSE_POLL_OPTION_CHANGE, type: COMPOSE_POLL_OPTION_CHANGE,
index, index,
title, title,
maxOptions,
}; };
} }

View File

@ -1,6 +1,8 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed"; import { blockDomainSuccess, unblockDomainSuccess } from "./domain_blocks_typed";
import { openModal } from './modal';
export * from "./domain_blocks_typed"; export * from "./domain_blocks_typed";
@ -150,3 +152,12 @@ export function expandDomainBlocksFail(error) {
error, error,
}; };
} }
export const initDomainBlockModal = account => dispatch => dispatch(openModal({
modalType: 'DOMAIN_BLOCK',
modalProps: {
domain: account.get('acct').split('@')[1],
acct: account.get('acct'),
accountId: account.get('id'),
},
}));

View File

@ -12,10 +12,6 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
export function fetchMutes() { export function fetchMutes() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchMutesRequest()); dispatch(fetchMutesRequest());
@ -92,26 +88,12 @@ export function expandMutesFail(error) {
export function initMuteModal(account) { export function initMuteModal(account) {
return dispatch => { return dispatch => {
dispatch({ dispatch(openModal({
type: MUTES_INIT_MODAL, modalType: 'MUTE',
account, modalProps: {
}); accountId: account.get('id'),
acct: account.get('acct'),
dispatch(openModal({ modalType: 'MUTE' })); },
}; }));
}
export function toggleHideNotifications() {
return dispatch => {
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
};
}
export function changeMuteDuration(duration) {
return dispatch => {
dispatch({
type: MUTES_CHANGE_DURATION,
duration,
});
}; };
} }

View File

@ -57,6 +57,38 @@ export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_POLICY_FETCH_REQUEST = 'NOTIFICATION_POLICY_FETCH_REQUEST';
export const NOTIFICATION_POLICY_FETCH_SUCCESS = 'NOTIFICATION_POLICY_FETCH_SUCCESS';
export const NOTIFICATION_POLICY_FETCH_FAIL = 'NOTIFICATION_POLICY_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_FETCH_REQUEST = 'NOTIFICATION_REQUESTS_FETCH_REQUEST';
export const NOTIFICATION_REQUESTS_FETCH_SUCCESS = 'NOTIFICATION_REQUESTS_FETCH_SUCCESS';
export const NOTIFICATION_REQUESTS_FETCH_FAIL = 'NOTIFICATION_REQUESTS_FETCH_FAIL';
export const NOTIFICATION_REQUESTS_EXPAND_REQUEST = 'NOTIFICATION_REQUESTS_EXPAND_REQUEST';
export const NOTIFICATION_REQUESTS_EXPAND_SUCCESS = 'NOTIFICATION_REQUESTS_EXPAND_SUCCESS';
export const NOTIFICATION_REQUESTS_EXPAND_FAIL = 'NOTIFICATION_REQUESTS_EXPAND_FAIL';
export const NOTIFICATION_REQUEST_FETCH_REQUEST = 'NOTIFICATION_REQUEST_FETCH_REQUEST';
export const NOTIFICATION_REQUEST_FETCH_SUCCESS = 'NOTIFICATION_REQUEST_FETCH_SUCCESS';
export const NOTIFICATION_REQUEST_FETCH_FAIL = 'NOTIFICATION_REQUEST_FETCH_FAIL';
export const NOTIFICATION_REQUEST_ACCEPT_REQUEST = 'NOTIFICATION_REQUEST_ACCEPT_REQUEST';
export const NOTIFICATION_REQUEST_ACCEPT_SUCCESS = 'NOTIFICATION_REQUEST_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUEST_ACCEPT_FAIL = 'NOTIFICATION_REQUEST_ACCEPT_FAIL';
export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMISS_REQUEST';
export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS';
export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS';
export const NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL = 'NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); });
@ -402,3 +434,270 @@ export function setBrowserPermission (value) {
value, value,
}; };
} }
export const fetchNotificationPolicy = () => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).get('/api/v1/notifications/policy').then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationPolicyRequest = () => ({
type: NOTIFICATION_POLICY_FETCH_REQUEST,
});
export const fetchNotificationPolicySuccess = policy => ({
type: NOTIFICATION_POLICY_FETCH_SUCCESS,
policy,
});
export const fetchNotificationPolicyFail = error => ({
type: NOTIFICATION_POLICY_FETCH_FAIL,
error,
});
export const updateNotificationsPolicy = params => (dispatch, getState) => {
dispatch(fetchNotificationPolicyRequest());
api(getState).put('/api/v1/notifications/policy', params).then(({ data }) => {
dispatch(fetchNotificationPolicySuccess(data));
}).catch(err => {
dispatch(fetchNotificationPolicyFail(err));
});
};
export const fetchNotificationRequests = () => (dispatch, getState) => {
const params = {};
if (getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
if (getState().getIn(['notificationRequests', 'items'])?.size > 0) {
params.since_id = getState().getIn(['notificationRequests', 'items', 0, 'id']);
}
dispatch(fetchNotificationRequestsRequest());
api(getState).get('/api/v1/notifications/requests', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchNotificationRequestsSuccess(response.data, next ? next.uri : null));
}).catch(err => {
dispatch(fetchNotificationRequestsFail(err));
});
};
export const fetchNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_FETCH_REQUEST,
});
export const fetchNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_FETCH_SUCCESS,
requests,
next,
});
export const fetchNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_FETCH_FAIL,
error,
});
export const expandNotificationRequests = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'next']);
if (!url || getState().getIn(['notificationRequests', 'isLoading'])) {
return;
}
dispatch(expandNotificationRequestsRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(expandNotificationRequestsSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationRequestsFail(err));
});
};
export const expandNotificationRequestsRequest = () => ({
type: NOTIFICATION_REQUESTS_EXPAND_REQUEST,
});
export const expandNotificationRequestsSuccess = (requests, next) => ({
type: NOTIFICATION_REQUESTS_EXPAND_SUCCESS,
requests,
next,
});
export const expandNotificationRequestsFail = error => ({
type: NOTIFICATION_REQUESTS_EXPAND_FAIL,
error,
});
export const fetchNotificationRequest = id => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
if (current.getIn(['item', 'id']) === id || current.get('isLoading')) {
return;
}
dispatch(fetchNotificationRequestRequest(id));
api(getState).get(`/api/v1/notifications/requests/${id}`).then(({ data }) => {
dispatch(fetchNotificationRequestSuccess(data));
}).catch(err => {
dispatch(fetchNotificationRequestFail(id, err));
});
};
export const fetchNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_FETCH_REQUEST,
id,
});
export const fetchNotificationRequestSuccess = request => ({
type: NOTIFICATION_REQUEST_FETCH_SUCCESS,
request,
});
export const fetchNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_FETCH_FAIL,
id,
error,
});
export const acceptNotificationRequest = id => (dispatch, getState) => {
dispatch(acceptNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/accept`).then(() => {
dispatch(acceptNotificationRequestSuccess(id));
}).catch(err => {
dispatch(acceptNotificationRequestFail(id, err));
});
};
export const acceptNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_REQUEST,
id,
});
export const acceptNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_ACCEPT_SUCCESS,
id,
});
export const acceptNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_ACCEPT_FAIL,
id,
error,
});
export const dismissNotificationRequest = id => (dispatch, getState) => {
dispatch(dismissNotificationRequestRequest(id));
api(getState).post(`/api/v1/notifications/requests/${id}/dismiss`).then(() =>{
dispatch(dismissNotificationRequestSuccess(id));
}).catch(err => {
dispatch(dismissNotificationRequestFail(id, err));
});
};
export const dismissNotificationRequestRequest = id => ({
type: NOTIFICATION_REQUEST_DISMISS_REQUEST,
id,
});
export const dismissNotificationRequestSuccess = id => ({
type: NOTIFICATION_REQUEST_DISMISS_SUCCESS,
id,
});
export const dismissNotificationRequestFail = (id, error) => ({
type: NOTIFICATION_REQUEST_DISMISS_FAIL,
id,
error,
});
export const fetchNotificationsForRequest = accountId => (dispatch, getState) => {
const current = getState().getIn(['notificationRequests', 'current']);
const params = { account_id: accountId };
if (current.getIn(['item', 'account']) === accountId) {
if (current.getIn(['notifications', 'isLoading'])) {
return;
}
if (current.getIn(['notifications', 'items'])?.size > 0) {
params.since_id = current.getIn(['notifications', 'items', 0, 'id']);
}
}
dispatch(fetchNotificationsForRequestRequest());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(fetchNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(fetchNotificationsForRequestFail(err));
});
};
export const fetchNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST,
});
export const fetchNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS,
notifications,
next,
});
export const fetchNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL,
error,
});
export const expandNotificationsForRequest = () => (dispatch, getState) => {
const url = getState().getIn(['notificationRequests', 'current', 'notifications', 'next']);
if (!url || getState().getIn(['notificationRequests', 'current', 'notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsForRequestRequest());
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsForRequestSuccess(response.data, next?.uri));
}).catch(err => {
dispatch(expandNotificationsForRequestFail(err));
});
};
export const expandNotificationsForRequestRequest = () => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_REQUEST,
});
export const expandNotificationsForRequestSuccess = (notifications, next) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_SUCCESS,
notifications,
next,
});
export const expandNotificationsForRequestFail = (error) => ({
type: NOTIFICATIONS_FOR_REQUEST_EXPAND_FAIL,
error,
});

View File

@ -143,11 +143,14 @@ export const showSearch = () => ({
type: SEARCH_SHOW, type: SEARCH_SHOW,
}); });
export const openURL = routerHistory => (dispatch, getState) => { export const openURL = (value, history, onFailure) => (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']); const signedIn = !!getState().getIn(['meta', 'me']);
if (!signedIn) { if (!signedIn) {
if (onFailure) {
onFailure();
}
return; return;
} }
@ -156,15 +159,21 @@ export const openURL = routerHistory => (dispatch, getState) => {
api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => { api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
if (response.data.accounts?.length > 0) { if (response.data.accounts?.length > 0) {
dispatch(importFetchedAccounts(response.data.accounts)); dispatch(importFetchedAccounts(response.data.accounts));
routerHistory.push(`/@${response.data.accounts[0].acct}`); history.push(`/@${response.data.accounts[0].acct}`);
} else if (response.data.statuses?.length > 0) { } else if (response.data.statuses?.length > 0) {
dispatch(importFetchedStatuses(response.data.statuses)); dispatch(importFetchedStatuses(response.data.statuses));
routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`); history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
} else if (onFailure) {
onFailure();
} }
dispatch(fetchSearchSuccess(response.data, value)); dispatch(fetchSearchSuccess(response.data, value));
}).catch(err => { }).catch(err => {
dispatch(fetchSearchFail(err)); dispatch(fetchSearchFail(err));
if (onFailure) {
onFailure();
}
}); });
}; };

View File

@ -1,7 +1,7 @@
import api from '../api'; import api from '../api';
import { ensureComposeIsVisible, setComposeToStatus } from './compose'; import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses } from './importer'; import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
@ -138,10 +138,10 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
api(getState).delete(`/api/v1/statuses/${id}`).then(response => { api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));
if (withRedraft) { if (withRedraft) {
dispatch(redraft(status, response.data.text, response.data.content_type)); dispatch(redraft(status, response.data.text, response.data.content_type));
ensureComposeIsVisible(getState, routerHistory); ensureComposeIsVisible(getState, routerHistory);
} }
}).catch(error => { }).catch(error => {

View File

@ -10,7 +10,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import LinkIcon from '@/material-icons/400-24px/link.svg?react'; import LinkIcon from '@/material-icons/400-24px/link.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
export default class AttachmentList extends ImmutablePureComponent { export default class AttachmentList extends ImmutablePureComponent {

View File

@ -0,0 +1,39 @@
import classNames from 'classnames';
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
import { Icon } from './icon';
interface Props {
value: string;
checked: boolean;
name: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
label: React.ReactNode;
}
export const CheckBox: React.FC<Props> = ({
name,
value,
checked,
onChange,
label,
}) => {
return (
<label className='check-box'>
<input
name={name}
type='checkbox'
value={value}
checked={checked}
onChange={onChange}
/>
<span className={classNames('check-box__input', { checked })}>
{checked && <Icon id='check' icon={DoneIcon} />}
</span>
<span>{label}</span>
</label>
);
};

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent, useCallback } from 'react'; import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
@ -11,12 +11,11 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import TuneIcon from '@/material-icons/400-24px/tune.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context'; import { ButtonInTabsBar, useColumnsContext } from 'flavours/glitch/features/ui/util/columns_context';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { useAppHistory } from './router'; import { useAppHistory } from './router';
const messages = defineMessages({ const messages = defineMessages({
@ -24,10 +23,12 @@ const messages = defineMessages({
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
}); });
const BackButton = ({ pinned, show }) => { const BackButton = ({ pinned, show, onlyIcon }) => {
const history = useAppHistory(); const history = useAppHistory();
const intl = useIntl();
const { multiColumn } = useColumnsContext(); const { multiColumn } = useColumnsContext();
const handleBackClick = useCallback(() => { const handleBackClick = useCallback(() => {
@ -40,18 +41,20 @@ const BackButton = ({ pinned, show }) => {
const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show); const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show);
if(!showButton) return null; if (!showButton) return null;
return (<button onClick={handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>);
return (
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
</button>
);
}; };
BackButton.propTypes = { BackButton.propTypes = {
pinned: PropTypes.bool, pinned: PropTypes.bool,
show: PropTypes.bool, show: PropTypes.bool,
onlyIcon: PropTypes.bool,
}; };
class ColumnHeader extends PureComponent { class ColumnHeader extends PureComponent {
@ -146,27 +149,31 @@ class ColumnHeader extends PureComponent {
} }
if (multiColumn && pinned) { if (multiColumn && pinned) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = ( moveButtons = (
<div key='move-buttons' className='column-header__setting-arrows'> <div className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button> <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button> <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
</div> </div>
); );
} else if (multiColumn && this.props.onPin) { } else if (multiColumn && this.props.onPin) {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
backButton = <BackButton pinned={pinned} show={showBackButton} />; backButton = <BackButton pinned={pinned} show={showBackButton} onlyIcon={!!title} />;
const collapsedContent = [ const collapsedContent = [
extraContent, extraContent,
]; ];
if (multiColumn) { if (multiColumn) {
collapsedContent.push(pinButton); collapsedContent.push(
collapsedContent.push(moveButtons); <div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>
);
} }
if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) { if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
@ -178,7 +185,7 @@ class ColumnHeader extends PureComponent {
onClick={this.handleToggleClick} onClick={this.handleToggleClick}
> >
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id='sliders' icon={TuneIcon} /> <Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />} {collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i> </i>
</button> </button>
@ -191,16 +198,19 @@ class ColumnHeader extends PureComponent {
<div className={wrapperClassName}> <div className={wrapperClassName}>
<h1 className={buttonClassName}> <h1 className={buttonClassName}>
{hasTitle && ( {hasTitle && (
<button onClick={this.handleTitleClick}> <>
<Icon id={icon} icon={iconComponent} className='column-header__icon' /> {showBackButton && backButton}
{title}
</button> <button onClick={this.handleTitleClick} className='column-header__title'>
{!showBackButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)} )}
{!hasTitle && backButton} {!hasTitle && showBackButton && backButton}
<div className='column-header__buttons'> <div className='column-header__buttons'>
{hasTitle && backButton}
{extraButton} {extraButton}
{collapseButton} {collapseButton}
</div> </div>

View File

@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { CircularProgress } from 'flavours/glitch/components/circular_progress'; import { CircularProgress } from 'flavours/glitch/components/circular_progress';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -298,7 +297,7 @@ class Dropdown extends PureComponent {
}) : ( }) : (
<IconButton <IconButton
icon={!open ? icon : 'close'} icon={!open ? icon : 'close'}
iconComponent={!open ? iconComponent : CloseIcon} iconComponent={iconComponent}
title={title} title={title}
active={open} active={open}
disabled={disabled} disabled={disabled}

View File

@ -5,10 +5,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { Icon } from 'flavours/glitch/components/icon';
import InlineAccount from 'flavours/glitch/components/inline_account'; import InlineAccount from 'flavours/glitch/components/inline_account';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
@ -68,7 +65,7 @@ class EditedTimestamp extends PureComponent {
return ( return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}> <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'> <button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' icon={ArrowDropDownIcon} /> <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
</button> </button>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -1,14 +1,12 @@
import logo from '@/images/logo.svg'; import logo from '@/images/logo.svg';
export const WordmarkLogo = () => ( export const WordmarkLogo: React.FC = () => (
<svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'> <svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'>
<title>Mastodon</title> <title>Mastodon</title>
<use xlinkHref='#logo-symbol-wordmark' /> <use xlinkHref='#logo-symbol-wordmark' />
</svg> </svg>
); );
export const SymbolLogo = () => ( export const SymbolLogo: React.FC = () => (
<img src={logo} alt='Mastodon' className='logo logo--icon' /> <img src={logo} alt='Mastodon' className='logo logo--icon' />
); );
export default WordmarkLogo;

View File

@ -1,6 +1,6 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; import illustration from '@/images/elephant_ui_working.svg';
const RegenerationIndicator = () => ( const RegenerationIndicator = () => (
<div className='regeneration-indicator'> <div className='regeneration-indicator'>

View File

@ -53,7 +53,6 @@ const messages = defineMessages({
}); });
const dateFormatOptions = { const dateFormatOptions = {
hour12: false,
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: '2-digit', day: '2-digit',
@ -103,7 +102,7 @@ const getUnitDelay = (units: string) => {
}; };
export const timeAgoString = ( export const timeAgoString = (
intl: IntlShape, intl: Pick<IntlShape, 'formatDate' | 'formatMessage'>,
date: Date, date: Date,
now: number, now: number,
year: number, year: number,

View File

@ -20,6 +20,7 @@ import Card from '../features/status/components/card';
// to use the progress bar to show download progress // to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
import { displayMedia, visibleReactions } from '../initial_state'; import { displayMedia, visibleReactions } from '../initial_state';
import AttachmentList from './attachment_list'; import AttachmentList from './attachment_list';
@ -73,9 +74,13 @@ export const defaultMediaVisibility = (status, settings) => {
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
<<<<<<< HEAD
static contextTypes = { static contextTypes = {
identity: PropTypes.object, identity: PropTypes.object,
}; };
=======
static contextType = SensitiveMediaContext;
>>>>>>> 3341db939cd077820ad598b0445d02ab2382eaf4
static propTypes = { static propTypes = {
containerId: PropTypes.string, containerId: PropTypes.string,
@ -132,8 +137,7 @@ class Status extends ImmutablePureComponent {
isCollapsed: false, isCollapsed: false,
autoCollapsed: false, autoCollapsed: false,
isExpanded: undefined, isExpanded: undefined,
showMedia: undefined, showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
statusId: undefined,
revealBehindCW: undefined, revealBehindCW: undefined,
showCard: false, showCard: false,
forceFilter: undefined, forceFilter: undefined,
@ -218,12 +222,6 @@ class Status extends ImmutablePureComponent {
updated = true; updated = true;
} }
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
update.showMedia = defaultMediaVisibility(nextProps.status, nextProps.settings);
update.statusId = nextProps.status.get('id');
updated = true;
}
if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) { if (nextProps.settings.getIn(['media', 'reveal_behind_cw']) !== prevState.revealBehindCW) {
update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']); update.revealBehindCW = nextProps.settings.getIn(['media', 'reveal_behind_cw']);
if (update.revealBehindCW) { if (update.revealBehindCW) {
@ -319,6 +317,18 @@ class Status extends ImmutablePureComponent {
if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) { if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
this.props.updateScrollBottom(snapshot.height - snapshot.top); this.props.updateScrollBottom(snapshot.height - snapshot.top);
} }
// This will potentially cause a wasteful redraw, but in most cases `Status` components are used
// with a `key` directly depending on their `id`, preventing re-use of the component across
// different IDs.
// But just in case this does change, reset the state on status change.
if (this.props.status?.get('id') !== prevProps.status?.get('id')) {
this.setState({
showMedia: defaultMediaVisibility(this.props.status, this.props.settings) && !(this.context?.hideMediaByDefault),
forceFilter: undefined,
});
}
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -381,7 +381,7 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar-spacer' /> <div className='status__action-bar-spacer' />
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>} <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a> </a>
</div> </div>
); );

View File

@ -80,7 +80,7 @@ export default class MediaContainer extends PureComponent {
return ( return (
<IntlProvider> <IntlProvider>
<> <>
{[].map.call(components, (component, i) => { {Array.from(components).map((component, i) => {
const componentName = component.getAttribute('data-component'); const componentName = component.getAttribute('data-component');
const Component = MEDIA_COMPONENTS[componentName]; const Component = MEDIA_COMPONENTS[componentName];
const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props')); const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props'));

View File

@ -10,7 +10,6 @@ import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react'; import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
@ -171,7 +170,8 @@ class About extends PureComponent {
<ol className='rules-list'> <ol className='rules-list'>
{server.get('rules').map(rule => ( {server.get('rules').map(rule => (
<li key={rule.get('id')}> <li key={rule.get('id')}>
<span className='rules-list__text'>{rule.get('text')}</span> <div className='rules-list__text'>{rule.get('text')}</div>
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
</li> </li>
))} ))}
</ol> </ol>
@ -189,18 +189,20 @@ class About extends PureComponent {
<> <>
<p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p> <p><FormattedMessage id='about.domain_blocks.preamble' defaultMessage='Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.' /></p>
<div className='about__domain-blocks'> {domainBlocks.get('items').size > 0 && (
{domainBlocks.get('items').map(block => ( <div className='about__domain-blocks'>
<div className='about__domain-blocks__domain' key={block.get('domain')}> {domainBlocks.get('items').map(block => (
<div className='about__domain-blocks__domain__header'> <div className='about__domain-blocks__domain' key={block.get('domain')}>
<h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6> <div className='about__domain-blocks__domain__header'>
<span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span> <h6><span title={`SHA-256: ${block.get('digest')}`}>{block.get('domain')}</span></h6>
</div> <span className='about__domain-blocks__domain__type' title={intl.formatMessage(severityMessages[block.get('severity')].explanation)}>{intl.formatMessage(severityMessages[block.get('severity')].title)}</span>
</div>
<p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p> <p>{(block.get('comment') || '').length > 0 ? block.get('comment') : <FormattedMessage id='about.domain_blocks.no_reason_available' defaultMessage='Reason not available' />}</p>
</div> </div>
))} ))}
</div> </div>
)}
</> </>
) : ( ) : (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>

View File

@ -0,0 +1,86 @@
import PropTypes from 'prop-types';
import { useState, useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BadgeIcon from '@/material-icons/400-24px/badge.svg?react';
import GlobeIcon from '@/material-icons/400-24px/globe.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
export const DomainPill = ({ domain, username, isSelf }) => {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const triggerRef = useRef(null);
const handleClick = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
const handleExpandClick = useCallback(() => {
setExpanded(!expanded);
}, [expanded, setExpanded]);
return (
<>
<button className={classNames('account__domain-pill', { active: open })} ref={triggerRef} onClick={handleClick}>{domain}</button>
<Overlay show={open} rootClose onHide={handleClick} offset={[5, 5]} target={triggerRef}>
{({ props }) => (
<div {...props} className='account__domain-pill__popout dropdown-animation'>
<div className='account__domain-pill__popout__header'>
<div className='account__domain-pill__popout__header__icon'><Icon icon={BadgeIcon} /></div>
<h3><FormattedMessage id='domain_pill.whats_in_a_handle' defaultMessage="What's in a handle?" /></h3>
</div>
<div className='account__domain-pill__popout__handle'>
<div className='account__domain-pill__popout__handle__label'>{isSelf ? <FormattedMessage id='domain_pill.your_handle' defaultMessage='Your handle:' /> : <FormattedMessage id='domain_pill.their_handle' defaultMessage='Their handle:' />}</div>
<div className='account__domain-pill__popout__handle__handle'>@{username}@{domain}</div>
</div>
<div className='account__domain-pill__popout__parts'>
<div>
<div className='account__domain-pill__popout__parts__icon'><Icon icon={AlternateEmailIcon} /></div>
<div>
<h6><FormattedMessage id='domain_pill.username' defaultMessage='Username' /></h6>
<p>{isSelf ? <FormattedMessage id='domain_pill.your_username' defaultMessage='Your unique identifier on this server. Its possible to find users with the same username on different servers.' /> : <FormattedMessage id='domain_pill.their_username' defaultMessage='Their unique identifier on their server. Its possible to find users with the same username on different servers.' />}</p>
</div>
</div>
<div>
<div className='account__domain-pill__popout__parts__icon'><Icon icon={GlobeIcon} /></div>
<div>
<h6><FormattedMessage id='domain_pill.server' defaultMessage='Server' /></h6>
<p>{isSelf ? <FormattedMessage id='domain_pill.your_server' defaultMessage='Your digital home, where all of your posts live. Dont like this one? Transfer servers at any time and bring your followers, too.' /> : <FormattedMessage id='domain_pill.their_server' defaultMessage='Their digital home, where all of their posts live.' />}</p>
</div>
</div>
</div>
<p>{isSelf ? <FormattedMessage id='domain_pill.who_you_are' defaultMessage='Because your handle says who you are and where you are, people can interact with you across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} /> : <FormattedMessage id='domain_pill.who_they_are' defaultMessage='Since handles say who someone is and where they are, you can interact with people across the social web of <button>ActivityPub-powered platforms</button>.' values={{ button: x => <button onClick={handleExpandClick} className='link-button'>{x}</button> }} />}</p>
{expanded && (
<>
<p><FormattedMessage id='domain_pill.activitypub_like_language' defaultMessage='ActivityPub is like the language Mastodon speaks with other social networks.' /></p>
<p><FormattedMessage id='domain_pill.activitypub_lets_connect' defaultMessage='It lets you connect and interact with people not just on Mastodon, but across different social apps too.' /></p>
</>
)}
</div>
)}
</Overlay>
</>
);
};
DomainPill.propTypes = {
username: PropTypes.string.isRequired,
domain: PropTypes.string.isRequired,
isSelf: PropTypes.bool,
};

View File

@ -21,8 +21,9 @@ import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button'; import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state'; import { autoPlayGif, me, domain as localDomain } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links'; import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -30,6 +31,8 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import AccountNoteContainer from '../containers/account_note_container'; import AccountNoteContainer from '../containers/account_note_container';
import FollowRequestNoteContainer from '../containers/follow_request_note_container'; import FollowRequestNoteContainer from '../containers/follow_request_note_container';
import { DomainPill } from './domain_pill';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -74,7 +77,7 @@ const messages = defineMessages({
const titleFromAccount = account => { const titleFromAccount = account => {
const displayName = account.get('display_name'); const displayName = account.get('display_name');
const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${domain}` : account.get('acct'); const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${localDomain}` : account.get('acct');
const prefix = displayName.trim().length === 0 ? account.get('username') : displayName; const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
return `${prefix} (@${acct})`; return `${prefix} (@${acct})`;
@ -84,7 +87,6 @@ const dateFormatOptions = {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
hour12: false,
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}; };
@ -167,7 +169,7 @@ class Header extends ImmutablePureComponent {
}; };
render () { render () {
const { account, hidden, intl, domain } = this.props; const { account, hidden, intl } = this.props;
const { signedIn, permissions } = this.context.identity; const { signedIn, permissions } = this.context.identity;
if (!account) { if (!account) {
@ -207,7 +209,7 @@ class Header extends ImmutablePureComponent {
if (me !== account.get('id')) { if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = <Button disabled><LoadingIndicator /></Button>;
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />; actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
@ -314,7 +316,8 @@ class Header extends ImmutablePureComponent {
const displayNameHtml = { __html: account.get('display_name_html') }; const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields'); const fields = account.get('fields');
const isLocal = account.get('acct').indexOf('@') === -1; const isLocal = account.get('acct').indexOf('@') === -1;
const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); const username = account.get('acct').split('@')[0];
const domain = isLocal ? localDomain : account.get('acct').split('@')[1];
const isIndexable = !account.get('noindex'); const isIndexable = !account.get('noindex');
const badges = []; const badges = [];
@ -348,15 +351,10 @@ class Header extends ImmutablePureComponent {
</a> </a>
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{!hidden && ( {!hidden && bellBtn}
<> {!hidden && shareBtn}
{actionBtn}
{bellBtn}
{shareBtn}
</>
)}
<DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' /> <DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' />
{!hidden && actionBtn}
</div> </div>
</div> </div>
@ -364,7 +362,9 @@ class Header extends ImmutablePureComponent {
<h1> <h1>
<span dangerouslySetInnerHTML={displayNameHtml} /> <span dangerouslySetInnerHTML={displayNameHtml} />
<small> <small>
<span>@{acct}</span> {lockedIcon} <span>@{username}<span className='invisible'>@{domain}</span></span>
<DomainPill username={username} domain={domain} isSelf={me === account.get('id')} />
{lockedIcon}
</small> </small>
</h1> </h1>
</div> </div>

View File

@ -12,7 +12,6 @@ import { Blurhash } from 'flavours/glitch/components/blurhash';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state'; import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
export default class MediaItem extends ImmutablePureComponent { export default class MediaItem extends ImmutablePureComponent {
static propTypes = { static propTypes = {

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