mirror of
https://github.com/funamitech/mastodon
synced 2024-11-27 14:29:03 +09:00
Merge branch 'main' of https://github.com/glitch-soc/mastodon
This commit is contained in:
commit
b75fc7c551
@ -1,31 +1,29 @@
|
||||
// For more details, see https://aka.ms/devcontainer.json.
|
||||
{
|
||||
"name": "Mastodon",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or the host.
|
||||
"runServices": ["app", "db", "redis"],
|
||||
|
||||
"forwardPorts": [3000, 4000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"containerEnv": {
|
||||
"ES_ENABLED": "",
|
||||
"LIBRE_TRANSLATE_ENDPOINT": ""
|
||||
},
|
||||
|
||||
"onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}",
|
||||
"postCreateCommand": ".devcontainer/post-create.sh",
|
||||
"waitFor": "postCreateCommand",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["EditorConfig.EditorConfig", "webben.browserslist"]
|
||||
}
|
||||
}
|
||||
|
@ -2,3 +2,7 @@ VAGRANT=true
|
||||
LOCAL_DOMAIN=mastodon.local
|
||||
BIND=0.0.0.0
|
||||
DB_HOST=/var/run/postgresql/
|
||||
|
||||
ES_ENABLED=true
|
||||
ES_HOST=localhost
|
||||
ES_PORT=9200
|
@ -1,6 +1,6 @@
|
||||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.54.2.
|
||||
# using RuboCop version 1.56.1.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
@ -61,38 +61,8 @@ Lint/EmptyBlock:
|
||||
- 'spec/fabricators/access_token_fabricator.rb'
|
||||
- 'spec/fabricators/conversation_fabricator.rb'
|
||||
- 'spec/fabricators/system_key_fabricator.rb'
|
||||
- 'spec/helpers/admin/action_logs_helper_spec.rb'
|
||||
- 'spec/lib/activitypub/adapter_spec.rb'
|
||||
- 'spec/models/account_alias_spec.rb'
|
||||
- 'spec/models/account_deletion_request_spec.rb'
|
||||
- 'spec/models/account_moderation_note_spec.rb'
|
||||
- 'spec/models/announcement_mute_spec.rb'
|
||||
- 'spec/models/announcement_reaction_spec.rb'
|
||||
- 'spec/models/announcement_spec.rb'
|
||||
- 'spec/models/backup_spec.rb'
|
||||
- 'spec/models/conversation_mute_spec.rb'
|
||||
- 'spec/models/custom_filter_keyword_spec.rb'
|
||||
- 'spec/models/custom_filter_spec.rb'
|
||||
- 'spec/models/device_spec.rb'
|
||||
- 'spec/models/encrypted_message_spec.rb'
|
||||
- 'spec/models/featured_tag_spec.rb'
|
||||
- 'spec/models/follow_recommendation_suppression_spec.rb'
|
||||
- 'spec/models/list_account_spec.rb'
|
||||
- 'spec/models/list_spec.rb'
|
||||
- 'spec/models/login_activity_spec.rb'
|
||||
- 'spec/models/mute_spec.rb'
|
||||
- 'spec/models/preview_card_spec.rb'
|
||||
- 'spec/models/preview_card_trend_spec.rb'
|
||||
- 'spec/models/relay_spec.rb'
|
||||
- 'spec/models/scheduled_status_spec.rb'
|
||||
- 'spec/models/status_stat_spec.rb'
|
||||
- 'spec/models/status_trend_spec.rb'
|
||||
- 'spec/models/system_key_spec.rb'
|
||||
- 'spec/models/tag_follow_spec.rb'
|
||||
- 'spec/models/unavailable_domain_spec.rb'
|
||||
- 'spec/models/user_invite_request_spec.rb'
|
||||
- 'spec/models/user_role_spec.rb'
|
||||
- 'spec/models/web/setting_spec.rb'
|
||||
|
||||
Lint/NonLocalExitFromIterator:
|
||||
Exclude:
|
||||
@ -135,7 +105,7 @@ Lint/UselessAssignment:
|
||||
|
||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||
Metrics/AbcSize:
|
||||
Max: 146
|
||||
Max: 144
|
||||
Exclude:
|
||||
- 'app/serializers/initial_state_serializer.rb'
|
||||
|
||||
@ -166,6 +136,19 @@ Naming/VariableNumber:
|
||||
- 'spec/models/domain_block_spec.rb'
|
||||
- 'spec/models/user_spec.rb'
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
# Configuration parameters: SafeMultiline.
|
||||
Performance/DeletePrefix:
|
||||
Exclude:
|
||||
- 'app/models/featured_tag.rb'
|
||||
|
||||
Performance/MapMethodChain:
|
||||
Exclude:
|
||||
- 'app/models/feed.rb'
|
||||
- 'lib/mastodon/cli/maintenance.rb'
|
||||
- 'spec/services/bulk_import_service_spec.rb'
|
||||
- 'spec/services/import_service_spec.rb'
|
||||
|
||||
RSpec/AnyInstance:
|
||||
Exclude:
|
||||
- 'spec/controllers/activitypub/inboxes_controller_spec.rb'
|
||||
@ -765,6 +748,15 @@ Style/RedundantFetchBlock:
|
||||
- 'config/initializers/paperclip.rb'
|
||||
- 'config/puma.rb'
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowMultipleReturnValues.
|
||||
Style/RedundantReturn:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/directories_controller.rb'
|
||||
- 'app/controllers/auth/confirmations_controller.rb'
|
||||
- 'app/lib/ostatus/tag_manager.rb'
|
||||
- 'app/models/form/import.rb'
|
||||
|
||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
|
||||
# AllowedMethods: present?, blank?, presence, try, try!
|
||||
|
33
CHANGELOG.md
33
CHANGELOG.md
@ -8,6 +8,9 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Added
|
||||
|
||||
- **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508))
|
||||
This reorganized scattered privacy and reach settings to a single place, as well as improve their wording.
|
||||
- **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525))
|
||||
- **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281))
|
||||
- **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866))
|
||||
The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained.
|
||||
@ -23,8 +26,18 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Add optional hCaptcha support** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25019), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25057), [Gargron](https://github.com/mastodon/mastodon/pull/25395), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26388))
|
||||
- **Add lines to threads in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24549), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24677), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24696), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24711), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24713), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24715), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24800), [teeerevor](https://github.com/mastodon/mastodon/pull/25706), [renchap](https://github.com/mastodon/mastodon/pull/25807))
|
||||
- **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561))
|
||||
- **Add `S3_DISABLE_CHECKSUM_MODE` environment variable for compatibility with some S3-compatible providers** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435))
|
||||
- **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510))
|
||||
- **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448))
|
||||
- Add support for `indexable` attribute on remote actors ([Gargron](https://github.com/mastodon/mastodon/pull/26485))
|
||||
- Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573))
|
||||
- Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489))
|
||||
This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards).
|
||||
- Add missing `instances` option to `tootctl search deploy` ([tribela](https://github.com/mastodon/mastodon/pull/26461))
|
||||
- Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542))
|
||||
- Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295))
|
||||
- Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443))
|
||||
- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384))
|
||||
- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447))
|
||||
- Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300))
|
||||
- Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155))
|
||||
- Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149))
|
||||
@ -43,7 +56,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085))
|
||||
- Add logging of websocket send errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25280))
|
||||
- Add time zone preference ([Gargron](https://github.com/mastodon/mastodon/pull/25342), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26025))
|
||||
- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400))
|
||||
- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26509))
|
||||
- Add `data-nosnippet` so Google doesn't use trending posts in snippets for `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25279))
|
||||
- Add card with who invited you to join when displaying rules on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23475))
|
||||
- Add missing primary keys to `accounts_tags` and `statuses_tags` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25210))
|
||||
@ -80,11 +93,12 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499))
|
||||
- **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302))
|
||||
- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267))
|
||||
- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459))
|
||||
- **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184))
|
||||
- **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248))
|
||||
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168))
|
||||
- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452))
|
||||
- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378))
|
||||
- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874))
|
||||
- **Change local and federated timelines to be in a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247))
|
||||
@ -97,7 +111,9 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386))
|
||||
This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas.
|
||||
To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`.
|
||||
- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362))
|
||||
- Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545))
|
||||
- Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396))
|
||||
- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416))
|
||||
- Change streaming `/metrics` to include additional metrics ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26299))
|
||||
- Change indexing frequency from 5 minutes to 1 minute, add locks to schedulers ([Gargron](https://github.com/mastodon/mastodon/pull/26304))
|
||||
- Change column link to add a better keyboard focus indicator ([teeerevor](https://github.com/mastodon/mastodon/pull/26278))
|
||||
@ -114,7 +130,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Change header backgrounds to use fewer different colors in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25577))
|
||||
- Change files to be deleted in batches instead of one-by-one ([Gargron](https://github.com/mastodon/mastodon/pull/23302), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25586), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25587))
|
||||
- Change emoji picker icon ([iparr](https://github.com/mastodon/mastodon/pull/25479))
|
||||
- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413))
|
||||
- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413), [c960657](https://github.com/mastodon/mastodon/pull/26538))
|
||||
- Change "bot" label to "automated" ([Gargron](https://github.com/mastodon/mastodon/pull/25356))
|
||||
- Change design of dropdowns in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25107))
|
||||
- Change wording of “Content cache retention period” setting to highlight destructive implications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23261))
|
||||
@ -172,6 +188,9 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- **Fix being unable to load past a full page of filtered posts in Home timeline** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24930))
|
||||
- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073))
|
||||
- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218))
|
||||
- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392))
|
||||
- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500))
|
||||
- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409))
|
||||
- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375))
|
||||
- Fix light theme select option for hashtags ([teeerevor](https://github.com/mastodon/mastodon/pull/26311))
|
||||
- Fix AVIF attachments ([c960657](https://github.com/mastodon/mastodon/pull/26264))
|
||||
@ -189,7 +208,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||
- Fix for "follows you" indicator in light web UI not readable ([vmstan](https://github.com/mastodon/mastodon/pull/25993))
|
||||
- Fix incorrect line break between icon and number of reposts & favourites ([edent](https://github.com/mastodon/mastodon/pull/26004))
|
||||
- Fix sounds not being loaded from assets host ([Signez](https://github.com/mastodon/mastodon/pull/25931))
|
||||
- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341))
|
||||
- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26482))
|
||||
- Fix trend calculation working on too many items at a time ([Gargron](https://github.com/mastodon/mastodon/pull/25835))
|
||||
- Fix dropdowns being disabled for logged out users in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25964))
|
||||
- Fix explore page being inaccessible when opted-out of trends in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25716))
|
||||
|
2
Gemfile
2
Gemfile
@ -110,7 +110,7 @@ group :test do
|
||||
gem 'fuubar', '~> 2.5'
|
||||
|
||||
# Extra RSpec extenion methods and helpers for sidekiq
|
||||
gem 'rspec-sidekiq', '~> 3.1'
|
||||
gem 'rspec-sidekiq', '~> 4.0'
|
||||
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
|
134
Gemfile.lock
134
Gemfile.lock
@ -39,47 +39,47 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actioncable (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionmailbox (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionmailer (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionpack (7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actiontext (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
actionview (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
@ -89,22 +89,22 @@ GEM
|
||||
activemodel (>= 4.1, < 7.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activejob (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activerecord (7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activestorage (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
activemodel (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activerecord (7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activestorage (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.7)
|
||||
activesupport (7.0.7.2)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@ -147,6 +147,7 @@ GEM
|
||||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||
net-http-persistent (~> 4.0)
|
||||
nokogiri (~> 1, >= 1.10.8)
|
||||
base64 (0.1.1)
|
||||
bcrypt (3.1.18)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
@ -451,7 +452,7 @@ GEM
|
||||
hashie (~> 5.0)
|
||||
memory_profiler (1.0.1)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.5.0)
|
||||
mime-types (3.5.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2023.0808)
|
||||
mini_mime (1.1.5)
|
||||
@ -481,7 +482,7 @@ GEM
|
||||
nokogiri (1.15.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.15.0)
|
||||
oj (3.16.0)
|
||||
omniauth (2.1.1)
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
@ -532,7 +533,7 @@ GEM
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
private_address_check (0.5.0)
|
||||
public_suffix (5.0.3)
|
||||
puma (6.3.0)
|
||||
puma (6.3.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@ -555,20 +556,20 @@ GEM
|
||||
rack
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rails (7.0.7)
|
||||
actioncable (= 7.0.7)
|
||||
actionmailbox (= 7.0.7)
|
||||
actionmailer (= 7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
actiontext (= 7.0.7)
|
||||
actionview (= 7.0.7)
|
||||
activejob (= 7.0.7)
|
||||
activemodel (= 7.0.7)
|
||||
activerecord (= 7.0.7)
|
||||
activestorage (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
rails (7.0.7.2)
|
||||
actioncable (= 7.0.7.2)
|
||||
actionmailbox (= 7.0.7.2)
|
||||
actionmailer (= 7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actiontext (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.7)
|
||||
railties (= 7.0.7.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
@ -583,9 +584,9 @@ GEM
|
||||
rails-i18n (7.0.7)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.0.7)
|
||||
actionpack (= 7.0.7)
|
||||
activesupport (= 7.0.7)
|
||||
railties (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@ -632,12 +633,15 @@ GEM
|
||||
rspec-expectations (~> 3.12)
|
||||
rspec-mocks (~> 3.12)
|
||||
rspec-support (~> 3.12)
|
||||
rspec-sidekiq (3.1.0)
|
||||
rspec-core (~> 3.0, >= 3.0.0)
|
||||
sidekiq (>= 2.4.0)
|
||||
rspec-support (3.12.0)
|
||||
rspec-sidekiq (4.0.1)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 8)
|
||||
rspec-support (3.12.1)
|
||||
rspec_chunked (0.6)
|
||||
rubocop (1.54.2)
|
||||
rubocop (1.56.1)
|
||||
base64 (~> 0.1.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
@ -645,7 +649,7 @@ GEM
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.0, < 2.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.29.0)
|
||||
@ -654,14 +658,14 @@ GEM
|
||||
rubocop (~> 1.41)
|
||||
rubocop-factory_bot (2.23.1)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-performance (1.18.0)
|
||||
rubocop-performance (1.19.0)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.20.2)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-rspec (2.22.0)
|
||||
rubocop-rspec (2.23.2)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
@ -909,7 +913,7 @@ DEPENDENCIES
|
||||
redis-namespace (~> 1.10)
|
||||
rqrcode (~> 2.2)
|
||||
rspec-rails (~> 6.0)
|
||||
rspec-sidekiq (~> 3.1)
|
||||
rspec-sidekiq (~> 4.0)
|
||||
rspec_chunked (~> 0.6)
|
||||
rubocop
|
||||
rubocop-capybara
|
||||
|
@ -1,8 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at <security@joinmastodon.org>.
|
||||
If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either:
|
||||
|
||||
You should _not_ report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.
|
||||
- open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new)
|
||||
- reach us at <security@joinmastodon.org>
|
||||
|
||||
You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk.
|
||||
|
||||
## Scope
|
||||
|
||||
|
43
Vagrantfile
vendored
43
Vagrantfile
vendored
@ -60,6 +60,37 @@ sudo usermod -a -G rvm $USER
|
||||
|
||||
SCRIPT
|
||||
|
||||
$provisionElasticsearch = <<SCRIPT
|
||||
# Install Elastic Search
|
||||
sudo apt install openjdk-17-jre-headless -y
|
||||
sudo wget -O /usr/share/keyrings/elasticsearch.asc https://artifacts.elastic.co/GPG-KEY-elasticsearch
|
||||
sudo sh -c 'echo "deb [signed-by=/usr/share/keyrings/elasticsearch.asc] https://artifacts.elastic.co/packages/7.x/apt stable main" > /etc/apt/sources.list.d/elastic-7.x.list'
|
||||
sudo apt update
|
||||
sudo apt install elasticsearch -y
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now elasticsearch
|
||||
|
||||
echo 'path.data: /var/lib/elasticsearch
|
||||
path.logs: /var/log/elasticsearch
|
||||
network.host: 0.0.0.0
|
||||
http.port: 9200
|
||||
discovery.seed_hosts: ["localhost"]
|
||||
cluster.initial_master_nodes: ["node-1"]' > /etc/elasticsearch/elasticsearch.yml
|
||||
|
||||
sudo systemctl restart elasticsearch
|
||||
|
||||
# Install Kibana
|
||||
sudo apt install kibana -y
|
||||
sudo systemctl enable --now kibana
|
||||
|
||||
echo 'server.host: "0.0.0.0"
|
||||
elasticsearch.hosts: ["http://localhost:9200"]' > /etc/kibana/kibana.yml
|
||||
|
||||
sudo systemctl restart kibana
|
||||
|
||||
SCRIPT
|
||||
|
||||
$provisionB = <<SCRIPT
|
||||
|
||||
source "/etc/profile.d/rvm.sh"
|
||||
@ -102,10 +133,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
|
||||
config.vm.provider :virtualbox do |vb|
|
||||
vb.name = "mastodon"
|
||||
vb.customize ["modifyvm", :id, "--memory", "4096"]
|
||||
# Increase the number of CPUs. Uncomment and adjust to
|
||||
# increase performance
|
||||
# vb.customize ["modifyvm", :id, "--cpus", "3"]
|
||||
vb.customize ["modifyvm", :id, "--memory", "8192"]
|
||||
vb.customize ["modifyvm", :id, "--cpus", "3"]
|
||||
|
||||
# Disable VirtualBox DNS proxy to skip long-delay IPv6 resolutions.
|
||||
# https://github.com/mitchellh/vagrant/issues/1172
|
||||
@ -141,9 +170,15 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.network :forwarded_port, guest: 3000, host: 3000
|
||||
config.vm.network :forwarded_port, guest: 4000, host: 4000
|
||||
config.vm.network :forwarded_port, guest: 8080, host: 8080
|
||||
config.vm.network :forwarded_port, guest: 9200, host: 9200
|
||||
config.vm.network :forwarded_port, guest: 9300, host: 9300
|
||||
config.vm.network :forwarded_port, guest: 9243, host: 9243
|
||||
config.vm.network :forwarded_port, guest: 5601, host: 5601
|
||||
|
||||
# Full provisioning script, only runs on first 'vagrant up' or with 'vagrant provision'
|
||||
config.vm.provision :shell, inline: $provisionA, privileged: false, reset: true
|
||||
# Run with elevated privileges for Elasticsearch installation
|
||||
config.vm.provision :shell, inline: $provisionElasticsearch, privileged: true
|
||||
config.vm.provision :shell, inline: $provisionB, privileged: false
|
||||
|
||||
config.vm.post_up_message = <<MESSAGE
|
||||
|
@ -6,7 +6,7 @@ class InstancesIndex < Chewy::Index
|
||||
index_scope ::Instance.searchable
|
||||
|
||||
root date_detection: false do
|
||||
field :domain, type: 'text', index_prefixes: { min_chars: 1 }
|
||||
field :domain, type: 'text', index_prefixes: { min_chars: 1, max_chars: 5 }
|
||||
field :accounts_count, type: 'long'
|
||||
end
|
||||
end
|
||||
|
13
app/controllers/api/v1/profile/avatars_controller.rb
Normal file
13
app/controllers/api/v1/profile/avatars_controller.rb
Normal file
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Profile::AvatarsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
|
||||
def destroy
|
||||
@account = current_account
|
||||
UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true)
|
||||
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
||||
render json: @account, serializer: REST::CredentialAccountSerializer
|
||||
end
|
||||
end
|
13
app/controllers/api/v1/profile/headers_controller.rb
Normal file
13
app/controllers/api/v1/profile/headers_controller.rb
Normal file
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Profile::HeadersController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
|
||||
def destroy
|
||||
@account = current_account
|
||||
UpdateAccountService.new.call(@account, { header: nil }, raise_error: true)
|
||||
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
||||
render json: @account, serializer: REST::CredentialAccountSerializer
|
||||
end
|
||||
end
|
27
app/controllers/settings/privacy_controller.rb
Normal file
27
app/controllers/settings/privacy_controller.rb
Normal file
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Settings::PrivacyController < Settings::BaseController
|
||||
before_action :set_account
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
if UpdateAccountService.new.call(@account, account_params.except(:settings))
|
||||
current_user.update!(settings_attributes: account_params[:settings])
|
||||
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
|
||||
redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg')
|
||||
else
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:discoverable, :unlocked, :show_collections, settings: UserSettings.keys)
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = current_account
|
||||
end
|
||||
end
|
@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController
|
||||
private
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :hide_collections, fields_attributes: [:name, :value])
|
||||
params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, fields_attributes: [:name, :value])
|
||||
end
|
||||
|
||||
def set_account
|
||||
|
@ -21,6 +21,8 @@ module ContextHelper
|
||||
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
|
||||
blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
|
||||
discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
|
||||
indexable: { 'toot' => 'http://joinmastodon.org/ns#', 'indexable' => 'toot:indexable' },
|
||||
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
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',
|
||||
|
@ -188,6 +188,7 @@ module LanguagesHelper
|
||||
|
||||
ISO_639_3 = {
|
||||
ast: ['Asturian', 'Asturianu'].freeze,
|
||||
chr: ['Cherokee', 'ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ'].freeze,
|
||||
ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
|
||||
cnr: ['Montenegrin', 'crnogorski'].freeze,
|
||||
jbo: ['Lojban', 'la .lojban.'].freeze,
|
||||
@ -200,6 +201,7 @@ module LanguagesHelper
|
||||
smj: ['Lule Sami', 'Julevsámegiella'].freeze,
|
||||
szl: ['Silesian', 'ślůnsko godka'].freeze,
|
||||
tok: ['Toki Pona', 'toki pona'].freeze,
|
||||
xal: ['Kalmyk', 'Хальмг келн'].freeze,
|
||||
zba: ['Balaibalan', 'باليبلن'].freeze,
|
||||
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
|
||||
}.freeze
|
||||
|
@ -18,22 +18,14 @@ delegate(document, '#account_display_name', 'input', ({ target }) => {
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '#account_avatar', 'change', ({ target }) => {
|
||||
const avatar = document.querySelector('.card .avatar img');
|
||||
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;
|
||||
});
|
||||
|
||||
delegate(document, '#account_header', 'change', ({ target }) => {
|
||||
const header = document.querySelector('.card .card__img img');
|
||||
const [file] = target.files || [];
|
||||
const url = file ? URL.createObjectURL(file) : header.dataset.originalSrc;
|
||||
|
||||
header.src = url;
|
||||
});
|
||||
|
||||
delegate(document, '#account_locked', 'change', ({ target }) => {
|
||||
const lock = document.querySelector('.card .display-name i');
|
||||
|
||||
|
@ -8,6 +8,7 @@ import classNames from 'classnames';
|
||||
import api from 'flavours/glitch/api';
|
||||
|
||||
const messages = defineMessages({
|
||||
legal: { id: 'report.categories.legal', defaultMessage: 'Legal' },
|
||||
other: { id: 'report.categories.other', defaultMessage: 'Other' },
|
||||
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
|
||||
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
|
||||
@ -150,6 +151,7 @@ class ReportReasonSelector extends PureComponent {
|
||||
return (
|
||||
<div className='report-reason-selector'>
|
||||
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
|
||||
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
|
||||
|
@ -18,7 +18,19 @@ export default class Column extends PureComponent {
|
||||
};
|
||||
|
||||
scrollTop () {
|
||||
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
|
||||
let scrollable = null;
|
||||
|
||||
if (this.props.bindToDocument) {
|
||||
scrollable = document.scrollingElement;
|
||||
} else {
|
||||
scrollable = this.node.querySelector('.scrollable');
|
||||
|
||||
// Some columns have nested `.scrollable` containers, with the outer one
|
||||
// being a wrapper while the actual scrollable content is deeper.
|
||||
if (scrollable.classList.contains('scrollable--flex')) {
|
||||
scrollable = scrollable?.querySelector('.scrollable') || scrollable;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
|
@ -196,9 +196,9 @@ class Header extends ImmutablePureComponent {
|
||||
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
|
||||
actionBtn = '';
|
||||
} else if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = <Button className={classNames({ 'button--with-bell': bellBtn !== '' })} 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'])) {
|
||||
actionBtn = <Button className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
|
||||
actionBtn = <Button className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
|
||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||
actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
|
||||
}
|
||||
|
@ -110,10 +110,10 @@ class Results extends PureComponent {
|
||||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results'>
|
||||
|
@ -610,7 +610,7 @@ class Status extends ImmutablePureComponent {
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 && list.get(i - 1)}
|
||||
previousId={i > 0 ? list.get(i - 1) : undefined}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
|
@ -1,761 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import Immutable from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import { initBlockModal } from 'flavours/glitch/actions/blocks';
|
||||
import { initBoostModal } from 'flavours/glitch/actions/boosts';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
directCompose,
|
||||
} from 'flavours/glitch/actions/compose';
|
||||
import {
|
||||
favourite,
|
||||
unfavourite,
|
||||
bookmark,
|
||||
unbookmark,
|
||||
reblog,
|
||||
unreblog,
|
||||
pin,
|
||||
unpin,
|
||||
} from 'flavours/glitch/actions/interactions';
|
||||
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { initMuteModal } from 'flavours/glitch/actions/mutes';
|
||||
import { initReport } from 'flavours/glitch/actions/reports';
|
||||
import {
|
||||
fetchStatus,
|
||||
muteStatus,
|
||||
unmuteStatus,
|
||||
deleteStatus,
|
||||
editStatus,
|
||||
hideStatus,
|
||||
revealStatus,
|
||||
translateStatus,
|
||||
undoStatusTranslation,
|
||||
} from 'flavours/glitch/actions/statuses';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
|
||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
||||
import StatusContainer from 'flavours/glitch/containers/status_container';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from 'flavours/glitch/selectors';
|
||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
import ActionBar from './components/action_bar';
|
||||
import DetailedStatus from './components/detailed_status';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
|
||||
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.' },
|
||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
|
||||
statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}' },
|
||||
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
|
||||
tootHeading: { id: 'account.posts_with_replies', defaultMessage: 'Posts and replies' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
||||
const getAncestorsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'inReplyTos']),
|
||||
], (statusId, inReplyTos) => {
|
||||
let ancestorsIds = Immutable.List();
|
||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||
let id = statusId;
|
||||
|
||||
while (id && !mutable.includes(id)) {
|
||||
mutable.unshift(id);
|
||||
id = inReplyTos.get(id);
|
||||
}
|
||||
});
|
||||
|
||||
return ancestorsIds;
|
||||
});
|
||||
|
||||
const getDescendantsIds = createSelector([
|
||||
(_, { id }) => id,
|
||||
state => state.getIn(['contexts', 'replies']),
|
||||
state => state.get('statuses'),
|
||||
], (statusId, contextReplies, statuses) => {
|
||||
let descendantsIds = [];
|
||||
const ids = [statusId];
|
||||
|
||||
while (ids.length > 0) {
|
||||
let id = ids.pop();
|
||||
const replies = contextReplies.get(id);
|
||||
|
||||
if (statusId !== id) {
|
||||
descendantsIds.push(id);
|
||||
}
|
||||
|
||||
if (replies) {
|
||||
replies.reverse().forEach(reply => {
|
||||
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
|
||||
if (insertAt !== -1) {
|
||||
descendantsIds.forEach((id, idx) => {
|
||||
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
|
||||
descendantsIds.splice(idx, 1);
|
||||
descendantsIds.splice(insertAt, 0, id);
|
||||
insertAt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Immutable.List(descendantsIds);
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
const status = getStatus(state, { id: props.params.statusId });
|
||||
|
||||
let ancestorsIds = Immutable.List();
|
||||
let descendantsIds = Immutable.List();
|
||||
|
||||
if (status) {
|
||||
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: state.getIn(['statuses', props.params.statusId, 'isLoading']),
|
||||
status,
|
||||
ancestorsIds,
|
||||
descendantsIds,
|
||||
settings: state.get('local_settings'),
|
||||
askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
|
||||
domain: state.getIn(['meta', 'domain']),
|
||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||
};
|
||||
};
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const truncate = (str, num) => {
|
||||
const arr = Array.from(str);
|
||||
if (arr.length > num) {
|
||||
return arr.slice(0, num).join('') + '…';
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const titleFromStatus = (intl, status) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
const username = status.getIn(['account', 'username']);
|
||||
const user = displayName.trim().length === 0 ? username : displayName;
|
||||
const text = status.get('search_index');
|
||||
const attachmentCount = status.get('media_attachments').size;
|
||||
|
||||
return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount });
|
||||
};
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
isLoading: PropTypes.bool,
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
ancestorsIds: ImmutablePropTypes.list.isRequired,
|
||||
descendantsIds: ImmutablePropTypes.list.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
askReplyConfirmation: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
state = {
|
||||
fullscreen: false,
|
||||
isExpanded: undefined,
|
||||
threadExpanded: undefined,
|
||||
statusId: undefined,
|
||||
loadedStatusId: undefined,
|
||||
showMedia: undefined,
|
||||
revealBehindCW: undefined,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
attachFullscreenListener(this.onFullScreenChange);
|
||||
this.props.dispatch(fetchStatus(this.props.params.statusId));
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
let update = {};
|
||||
let updated = false;
|
||||
|
||||
if (props.params.statusId && state.statusId !== props.params.statusId) {
|
||||
props.dispatch(fetchStatus(props.params.statusId));
|
||||
update.threadExpanded = undefined;
|
||||
update.statusId = props.params.statusId;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']);
|
||||
if (revealBehindCW !== state.revealBehindCW) {
|
||||
update.revealBehindCW = revealBehindCW;
|
||||
if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (props.status && state.loadedStatusId !== props.status.get('id')) {
|
||||
update.showMedia = defaultMediaVisibility(props.status, props.settings);
|
||||
update.loadedStatusId = props.status.get('id');
|
||||
update.isExpanded = autoUnfoldCW(props.settings, props.status);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
return updated ? update : null;
|
||||
}
|
||||
|
||||
handleToggleHidden = () => {
|
||||
const { status } = this.props;
|
||||
|
||||
if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
|
||||
if (status.get('hidden')) {
|
||||
this.props.dispatch(revealStatus(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(hideStatus(status.get('id')));
|
||||
}
|
||||
} else if (this.props.status.get('spoiler_text')) {
|
||||
this.setExpansion(!this.state.isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleMediaVisibility = () => {
|
||||
this.setState({ showMedia: !this.state.showMedia });
|
||||
};
|
||||
|
||||
handleModalFavourite = (status) => {
|
||||
this.props.dispatch(favourite(status));
|
||||
};
|
||||
|
||||
handleFavouriteClick = (status, e) => {
|
||||
const { dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
if ((e && e.shiftKey) || !favouriteModal) {
|
||||
this.handleModalFavourite(status);
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'FAVOURITE',
|
||||
modalProps: {
|
||||
status,
|
||||
onFavourite: this.handleModalFavourite,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'favourite',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handlePin = (status) => {
|
||||
if (status.get('pinned')) {
|
||||
this.props.dispatch(unpin(status));
|
||||
} else {
|
||||
this.props.dispatch(pin(status));
|
||||
}
|
||||
};
|
||||
|
||||
handleReplyClick = (status) => {
|
||||
const { askReplyConfirmation, dispatch, intl } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (askReplyConfirmation) {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
message: intl.formatMessage(messages.replyMessage),
|
||||
confirm: intl.formatMessage(messages.replyConfirm),
|
||||
onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
|
||||
onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
dispatch(replyCompose(status, this.context.router.history));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reply',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleModalReblog = (status, privacy) => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
dispatch(reblog(status, privacy));
|
||||
}
|
||||
};
|
||||
|
||||
handleReblogClick = (status, e) => {
|
||||
const { settings, dispatch } = this.props;
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
|
||||
} else if ((e && e.shiftKey) || !boostModal) {
|
||||
this.handleModalReblog(status);
|
||||
} else {
|
||||
dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
|
||||
}
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('url'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleBookmarkClick = (status) => {
|
||||
if (status.get('bookmarked')) {
|
||||
this.props.dispatch(unbookmark(status));
|
||||
} else {
|
||||
this.props.dispatch(bookmark(status));
|
||||
}
|
||||
};
|
||||
|
||||
handleDeleteClick = (status, history, withRedraft = false) => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), history, withRedraft));
|
||||
} else {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
|
||||
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
handleEditClick = (status, history) => {
|
||||
this.props.dispatch(editStatus(status.get('id'), history));
|
||||
};
|
||||
|
||||
handleDirectClick = (account, router) => {
|
||||
this.props.dispatch(directCompose(account, router));
|
||||
};
|
||||
|
||||
handleMentionClick = (account, router) => {
|
||||
this.props.dispatch(mentionCompose(account, router));
|
||||
};
|
||||
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'MEDIA',
|
||||
modalProps: { statusId: this.props.status.get('id'), media, index, lang },
|
||||
}));
|
||||
};
|
||||
|
||||
handleOpenVideo = (media, lang, options) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'VIDEO',
|
||||
modalProps: { statusId: this.props.status.get('id'), media, lang, options },
|
||||
}));
|
||||
};
|
||||
|
||||
handleHotkeyOpenMedia = e => {
|
||||
const { status } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
|
||||
} else {
|
||||
this.handleOpenMedia(status.get('media_attachments'), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleMuteClick = (account) => {
|
||||
this.props.dispatch(initMuteModal(account));
|
||||
};
|
||||
|
||||
handleConversationMuteClick = (status) => {
|
||||
if (status.get('muted')) {
|
||||
this.props.dispatch(unmuteStatus(status.get('id')));
|
||||
} else {
|
||||
this.props.dispatch(muteStatus(status.get('id')));
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleAll = () => {
|
||||
const { status, ancestorsIds, descendantsIds, settings } = this.props;
|
||||
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
|
||||
let { isExpanded } = this.state;
|
||||
|
||||
if (settings.getIn(['content_warnings', 'shared_state']))
|
||||
isExpanded = !status.get('hidden');
|
||||
|
||||
if (!isExpanded) {
|
||||
this.props.dispatch(revealStatus(statusIds));
|
||||
} else {
|
||||
this.props.dispatch(hideStatus(statusIds));
|
||||
}
|
||||
|
||||
this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
|
||||
};
|
||||
|
||||
handleTranslate = status => {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
if (status.get('translation')) {
|
||||
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
|
||||
} else {
|
||||
dispatch(translateStatus(status.get('id')));
|
||||
}
|
||||
};
|
||||
|
||||
handleBlockClick = (status) => {
|
||||
const { dispatch } = this.props;
|
||||
const account = status.get('account');
|
||||
dispatch(initBlockModal(account));
|
||||
};
|
||||
|
||||
handleReport = (status) => {
|
||||
this.props.dispatch(initReport(status.get('account'), status));
|
||||
};
|
||||
|
||||
handleEmbed = (status) => {
|
||||
this.props.dispatch(openModal({
|
||||
modalType: 'EMBED',
|
||||
modalProps: { id: status.get('id') },
|
||||
}));
|
||||
};
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.handleMoveUp(this.props.status.get('id'));
|
||||
};
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.handleMoveDown(this.props.status.get('id'));
|
||||
};
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.handleReplyClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
this.handleFavouriteClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyBoost = () => {
|
||||
this.handleReblogClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyBookmark = () => {
|
||||
this.handleBookmarkClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.handleMentionClick(this.props.status);
|
||||
};
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size - 1, true);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index, true);
|
||||
} else {
|
||||
this._selectChild(index - 1, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleMoveDown = id => {
|
||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||
|
||||
if (id === status.get('id')) {
|
||||
this._selectChild(ancestorsIds.size + 1, false);
|
||||
} else {
|
||||
let index = ancestorsIds.indexOf(id);
|
||||
|
||||
if (index === -1) {
|
||||
index = descendantsIds.indexOf(id);
|
||||
this._selectChild(ancestorsIds.size + index + 2, false);
|
||||
} else {
|
||||
this._selectChild(index + 1, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_selectChild (index, align_top) {
|
||||
const container = this.node;
|
||||
const element = container.querySelectorAll('.focusable')[index];
|
||||
|
||||
if (element) {
|
||||
if (align_top && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
renderChildren (list, ancestors) {
|
||||
const { params: { statusId } } = this.props;
|
||||
|
||||
return list.map((id, i) => (
|
||||
<StatusContainer
|
||||
key={id}
|
||||
id={id}
|
||||
expanded={this.state.threadExpanded}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 && list.get(i - 1)}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
setExpansion = value => {
|
||||
this.setState({ isExpanded: value });
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
setColumnRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const { status, ancestorsIds, multiColumn } = this.props;
|
||||
|
||||
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.node?.querySelector('.detailed-status__wrapper')?.scrollIntoView(true);
|
||||
|
||||
// In the single-column interface, `scrollIntoView` will put the post behind the header,
|
||||
// so compensate for that.
|
||||
if (!multiColumn) {
|
||||
const offset = document.querySelector('.column-header__wrapper')?.getBoundingClientRect()?.bottom;
|
||||
if (offset) {
|
||||
const scrollingElement = document.scrollingElement || document.body;
|
||||
scrollingElement.scrollBy(0, -offset);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
detachFullscreenListener(this.onFullScreenChange);
|
||||
}
|
||||
|
||||
onFullScreenChange = () => {
|
||||
this.setState({ fullscreen: isFullscreen() });
|
||||
};
|
||||
|
||||
render () {
|
||||
let ancestors, descendants;
|
||||
const { isLoading, status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
|
||||
);
|
||||
}
|
||||
|
||||
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
|
||||
|
||||
if (ancestorsIds && ancestorsIds.size > 0) {
|
||||
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
|
||||
}
|
||||
|
||||
if (descendantsIds && descendantsIds.size > 0) {
|
||||
descendants = <>{this.renderChildren(descendantsIds)}</>;
|
||||
}
|
||||
|
||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
const handlers = {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
bookmark: this.handleHotkeyBookmark,
|
||||
mention: this.handleHotkeyMention,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
toggleSpoiler: this.handleToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
};
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}>
|
||||
<ColumnHeader
|
||||
icon='comment'
|
||||
title={intl.formatMessage(messages.tootHeading)}
|
||||
onClick={this.handleHeaderClick}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll}><Icon id={!isExpanded ? 'eye-slash' : 'eye'} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollContainer scrollKey='thread'>
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
|
||||
{ancestors}
|
||||
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader(intl, status, false, isExpanded)}>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
status={status}
|
||||
settings={settings}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
expanded={isExpanded}
|
||||
onToggleHidden={this.handleToggleHidden}
|
||||
onTranslate={this.handleTranslate}
|
||||
domain={domain}
|
||||
showMedia={this.state.showMedia}
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
key={`action-bar-${status.get('id')}`}
|
||||
status={status}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onReblog={this.handleReblogClick}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onEdit={this.handleEditClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMention={this.handleMentionClick}
|
||||
onMute={this.handleMuteClick}
|
||||
onMuteConversation={this.handleConversationMuteClick}
|
||||
onBlock={this.handleBlockClick}
|
||||
onReport={this.handleReport}
|
||||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
||||
{descendants}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromStatus(intl, status)}</title>
|
||||
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
|
||||
<link rel='canonical' href={status.get('url')} />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps)(Status));
|
@ -126,7 +126,10 @@ export default class ModalRoot extends PureComponent {
|
||||
{visible && (
|
||||
<>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
||||
{(SpecificComponent) => {
|
||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
|
||||
}}
|
||||
</BundleContainer>
|
||||
|
||||
<Helmet>
|
||||
|
@ -63,7 +63,7 @@ class ReportModal extends ImmutablePureComponent {
|
||||
dispatch(submitReport({
|
||||
account_id: accountId,
|
||||
status_ids: selectedStatusIds.toArray(),
|
||||
selected_domains: selectedDomains.toArray(),
|
||||
forward_to_domains: selectedDomains.toArray(),
|
||||
comment,
|
||||
forward: selectedDomains.size > 0,
|
||||
category,
|
||||
|
@ -14,7 +14,6 @@ import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
|
||||
import { loadLocale, getLocale } from 'flavours/glitch/locales';
|
||||
import { loadPolyfills } from 'flavours/glitch/polyfills';
|
||||
import ready from 'flavours/glitch/ready';
|
||||
|
||||
const messages = defineMessages({
|
||||
usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' },
|
||||
@ -42,159 +41,157 @@ function main() {
|
||||
};
|
||||
};
|
||||
|
||||
ready(() => {
|
||||
const locale = document.documentElement.lang;
|
||||
const locale = document.documentElement.lang;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeFormat: false,
|
||||
});
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeFormat: false,
|
||||
});
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: 'short',
|
||||
hour12: false,
|
||||
});
|
||||
const timeFormat = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: 'short',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const formatMessage = ({ id, defaultMessage }, values) => {
|
||||
const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
|
||||
return messageFormat.format(values);
|
||||
};
|
||||
const formatMessage = ({ id, defaultMessage }, values) => {
|
||||
const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
|
||||
return messageFormat.format(values);
|
||||
};
|
||||
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const formattedDate = dateTimeFormat.format(datetime);
|
||||
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const formattedDate = dateTimeFormat.format(datetime);
|
||||
|
||||
content.title = formattedDate;
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
content.title = formattedDate;
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
|
||||
const isToday = date => {
|
||||
const today = new Date();
|
||||
const isToday = date => {
|
||||
const today = new Date();
|
||||
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
|
||||
let formattedContent;
|
||||
let formattedContent;
|
||||
|
||||
if (isToday(datetime)) {
|
||||
const formattedTime = timeFormat.format(datetime);
|
||||
if (isToday(datetime)) {
|
||||
const formattedTime = timeFormat.format(datetime);
|
||||
|
||||
formattedContent = todayFormat.format({ time: formattedTime });
|
||||
} else {
|
||||
formattedContent = dateFormat.format(datetime);
|
||||
}
|
||||
|
||||
content.title = formattedContent;
|
||||
content.textContent = formattedContent;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const now = new Date();
|
||||
|
||||
const timeGiven = content.getAttribute('datetime').includes('T');
|
||||
content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
|
||||
content.textContent = timeAgoString({
|
||||
formatMessage,
|
||||
formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
|
||||
}, datetime, now, now.getFullYear(), timeGiven);
|
||||
});
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
if (reactComponents.length > 0) {
|
||||
import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
|
||||
.then(({ default: MediaContainer }) => {
|
||||
[].forEach.call(reactComponents, (component) => {
|
||||
[].forEach.call(component.children, (child) => {
|
||||
component.removeChild(child);
|
||||
});
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
|
||||
const root = createRoot(content);
|
||||
root.render(<MediaContainer locale={locale} components={reactComponents} />);
|
||||
document.body.appendChild(content);
|
||||
scrollToDetailedStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
scrollToDetailedStatus();
|
||||
});
|
||||
formattedContent = todayFormat.format({ time: formattedTime });
|
||||
} else {
|
||||
scrollToDetailedStatus();
|
||||
formattedContent = dateFormat.format(datetime);
|
||||
}
|
||||
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
|
||||
const username = document.getElementById('user_account_attributes_username');
|
||||
content.title = formattedContent;
|
||||
content.textContent = formattedContent;
|
||||
});
|
||||
|
||||
if (username.value && username.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
|
||||
username.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}).catch(() => {
|
||||
username.setCustomValidity('');
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const now = new Date();
|
||||
|
||||
const timeGiven = content.getAttribute('datetime').includes('T');
|
||||
content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
|
||||
content.textContent = timeAgoString({
|
||||
formatMessage,
|
||||
formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
|
||||
}, datetime, now, now.getFullYear(), timeGiven);
|
||||
});
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
if (reactComponents.length > 0) {
|
||||
import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
|
||||
.then(({ default: MediaContainer }) => {
|
||||
[].forEach.call(reactComponents, (component) => {
|
||||
[].forEach.call(component.children, (child) => {
|
||||
component.removeChild(child);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
||||
const content = document.createElement('div');
|
||||
|
||||
const root = createRoot(content);
|
||||
root.render(<MediaContainer locale={locale} components={reactComponents} />);
|
||||
document.body.appendChild(content);
|
||||
scrollToDetailedStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
scrollToDetailedStatus();
|
||||
});
|
||||
} else {
|
||||
scrollToDetailedStatus();
|
||||
}
|
||||
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
|
||||
const username = document.getElementById('user_account_attributes_username');
|
||||
|
||||
if (username.value && username.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
|
||||
username.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}).catch(() => {
|
||||
username.setCustomValidity('');
|
||||
}
|
||||
}, 500, { leading: false, trailing: true }));
|
||||
});
|
||||
} else {
|
||||
username.setCustomValidity('');
|
||||
}
|
||||
}, 500, { leading: false, trailing: true }));
|
||||
|
||||
delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
|
||||
const password = document.getElementById('user_password');
|
||||
const confirmation = document.getElementById('user_password_confirmation');
|
||||
if (!confirmation) return;
|
||||
delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
|
||||
const password = document.getElementById('user_password');
|
||||
const confirmation = document.getElementById('user_password_confirmation');
|
||||
if (!confirmation) return;
|
||||
|
||||
if (confirmation.value && confirmation.value.length > password.maxLength) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
|
||||
} else if (password.value && password.value !== confirmation.value) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
|
||||
} else {
|
||||
confirmation.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
if (confirmation.value && confirmation.value.length > password.maxLength) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
|
||||
} else if (password.value && password.value !== confirmation.value) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
|
||||
} else {
|
||||
confirmation.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '.status__content__spoiler-link', 'click', function() {
|
||||
const statusEl = this.parentNode.parentNode;
|
||||
delegate(document, '.status__content__spoiler-link', 'click', function() {
|
||||
const statusEl = this.parentNode.parentNode;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
|
||||
}
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode.parentNode;
|
||||
const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
|
||||
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
|
||||
});
|
||||
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode.parentNode;
|
||||
const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
|
||||
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
|
@ -147,10 +147,6 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-multiple-columns &.button--with-bell {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
|
@ -1413,6 +1413,44 @@ img.modal-warning {
|
||||
}
|
||||
}
|
||||
|
||||
&__choices {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
&__choice {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $darker-text-color;
|
||||
margin-bottom: 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint - 1px) {
|
||||
&__choices {
|
||||
flex-direction: column;
|
||||
|
||||
&__choice {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-button {
|
||||
font-size: inherit;
|
||||
display: inline;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -159,6 +159,7 @@
|
||||
|
||||
&.active {
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -310,9 +310,19 @@ code {
|
||||
border-radius: 4px;
|
||||
background: url('images/void.png');
|
||||
|
||||
&[src$='missing.png'] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&#account_avatar-preview {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
199
app/javascript/mastodon/components/__tests__/hashtag_bar.tsx
Normal file
199
app/javascript/mastodon/components/__tests__/hashtag_bar.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
import type { StatusLike } from '../hashtag_bar';
|
||||
import { computeHashtagBarForStatus } from '../hashtag_bar';
|
||||
|
||||
function createStatus(
|
||||
content: string,
|
||||
hashtags: string[],
|
||||
hasMedia = false,
|
||||
spoilerText?: string,
|
||||
) {
|
||||
return fromJS({
|
||||
tags: hashtags.map((name) => ({ name })),
|
||||
contentHtml: content,
|
||||
media_attachments: hasMedia ? ['fakeMedia'] : [],
|
||||
spoiler_text: spoilerText,
|
||||
}) as unknown as StatusLike; // need to force the type here, as it is not properly defined
|
||||
}
|
||||
|
||||
describe('computeHashtagBarForStatus', () => {
|
||||
it('does nothing when there are no tags', () => {
|
||||
const status = createStatus('<p>Simple text</p>', []);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Simple text</p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('displays out of band hashtags in the bar', () => {
|
||||
const status = createStatus(
|
||||
'<p>Simple text <a href="test">#hashtag</a></p>',
|
||||
['hashtag', 'test'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual(['test']);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Simple text <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('extract tags from the last line', () => {
|
||||
const status = createStatus(
|
||||
'<p>Simple text</p><p><a href="test">#hashtag</a></p>',
|
||||
['hashtag'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual(['hashtag']);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Simple text</p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not include tags from content', () => {
|
||||
const status = createStatus(
|
||||
'<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>',
|
||||
['hashtag'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Simple text with a <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('works with one line status and hashtags', () => {
|
||||
const status = createStatus(
|
||||
'<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>',
|
||||
['hashtag', 'test'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('de-duplicate accentuated characters with case differences', () => {
|
||||
const status = createStatus(
|
||||
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
|
||||
['éaa'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual(['Éaa']);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Text</p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles server-side normalized tags with accentuated characters', () => {
|
||||
const status = createStatus(
|
||||
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
|
||||
['eaa'], // The server may normalize the hashtags in the `tags` attribute
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual(['Éaa']);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Text</p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not display in bar a hashtag in content with a case difference', () => {
|
||||
const status = createStatus(
|
||||
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
|
||||
['éaa'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>Text <a href="test">#Éaa</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not modify a status with a line of hashtags only', () => {
|
||||
const status = createStatus(
|
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||
['test', 'hashtag'],
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
|
||||
const status = createStatus(
|
||||
'<p>This is my content! <a href="test">#hashtag</a></p>',
|
||||
['hashtag'],
|
||||
true,
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p>This is my content! <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => {
|
||||
const status = createStatus(
|
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||
['test', 'hashtag'],
|
||||
true,
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual(['test', 'hashtag']);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => {
|
||||
const status = createStatus(
|
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||
['test', 'hashtag'],
|
||||
true,
|
||||
'My CW text',
|
||||
);
|
||||
|
||||
const { hashtagsInBar, statusContentProps } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
expect(hashtagsInBar).toEqual([]);
|
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
|
||||
);
|
||||
});
|
||||
});
|
@ -8,6 +8,7 @@ import classNames from 'classnames';
|
||||
import api from 'mastodon/api';
|
||||
|
||||
const messages = defineMessages({
|
||||
legal: { id: 'report.categories.legal', defaultMessage: 'Legal' },
|
||||
other: { id: 'report.categories.other', defaultMessage: 'Other' },
|
||||
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
|
||||
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
|
||||
@ -150,6 +151,7 @@ class ReportReasonSelector extends PureComponent {
|
||||
return (
|
||||
<div className='report-reason-selector'>
|
||||
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
|
||||
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
|
||||
|
@ -16,7 +16,19 @@ export default class Column extends PureComponent {
|
||||
};
|
||||
|
||||
scrollTop () {
|
||||
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
|
||||
let scrollable = null;
|
||||
|
||||
if (this.props.bindToDocument) {
|
||||
scrollable = document.scrollingElement;
|
||||
} else {
|
||||
scrollable = this.node.querySelector('.scrollable');
|
||||
|
||||
// Some columns have nested `.scrollable` containers, with the outer one
|
||||
// being a wrapper while the actual scrollable content is deeper.
|
||||
if (scrollable.classList.contains('scrollable--flex')) {
|
||||
scrollable = scrollable?.querySelector('.scrollable') || scrollable;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
|
234
app/javascript/mastodon/components/hashtag_bar.tsx
Normal file
234
app/javascript/mastodon/components/hashtag_bar.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { List, Record } from 'immutable';
|
||||
|
||||
import { groupBy, minBy } from 'lodash';
|
||||
|
||||
import { getStatusContent } from './status_content';
|
||||
|
||||
// About two lines on desktop
|
||||
const VISIBLE_HASHTAGS = 7;
|
||||
|
||||
// Those types are not correct, they need to be replaced once this part of the state is typed
|
||||
export type TagLike = Record<{ name: string }>;
|
||||
export type StatusLike = Record<{
|
||||
tags: List<TagLike>;
|
||||
contentHTML: string;
|
||||
media_attachments: List<unknown>;
|
||||
spoiler_text?: string;
|
||||
}>;
|
||||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
return (
|
||||
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
).normalize('NFKC');
|
||||
}
|
||||
|
||||
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
|
||||
return (
|
||||
element instanceof HTMLAnchorElement &&
|
||||
// it may be a <a> starting with a hashtag
|
||||
(element.textContent?.[0] === '#' ||
|
||||
// or a #<a>
|
||||
element.previousSibling?.textContent?.[
|
||||
element.previousSibling.textContent.length - 1
|
||||
] === '#')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicates from an hashtag list, case-insensitive, keeping only the best one
|
||||
* "Best" here is defined by the one with the more casing difference (ie, the most camel-cased one)
|
||||
* @param hashtags The list of hashtags
|
||||
* @returns The input hashtags, but with only 1 occurence of each (case-insensitive)
|
||||
*/
|
||||
function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
|
||||
const groups = groupBy(hashtags, (tag) =>
|
||||
tag.normalize('NFKD').toLowerCase(),
|
||||
);
|
||||
|
||||
return Object.values(groups).map((tags) => {
|
||||
if (tags.length === 1) return tags[0];
|
||||
|
||||
// The best match is the one where we have the less difference between upper and lower case letter count
|
||||
const best = minBy(tags, (tag) => {
|
||||
const upperCase = Array.from(tag).reduce(
|
||||
(acc, char) => (acc += char.toUpperCase() === char ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const lowerCase = tag.length - upperCase;
|
||||
|
||||
return Math.abs(lowerCase - upperCase);
|
||||
});
|
||||
|
||||
return best ?? tags[0];
|
||||
});
|
||||
}
|
||||
|
||||
// Create the collator once, this is much more efficient
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
sensitivity: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently
|
||||
});
|
||||
|
||||
function localeAwareInclude(collection: string[], value: string) {
|
||||
const normalizedValue = value.normalize('NFKC');
|
||||
|
||||
return !!collection.find(
|
||||
(item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 0,
|
||||
);
|
||||
}
|
||||
|
||||
// We use an intermediate function here to make it easier to test
|
||||
export function computeHashtagBarForStatus(status: StatusLike): {
|
||||
statusContentProps: { statusContent: string };
|
||||
hashtagsInBar: string[];
|
||||
} {
|
||||
let statusContent = getStatusContent(status);
|
||||
|
||||
const tagNames = status
|
||||
.get('tags')
|
||||
.map((tag) => tag.get('name'))
|
||||
.toJS();
|
||||
|
||||
// this is returned if we stop the processing early, it does not change what is displayed
|
||||
const defaultResult = {
|
||||
statusContentProps: { statusContent },
|
||||
hashtagsInBar: [],
|
||||
};
|
||||
|
||||
// return early if this status does not have any tags
|
||||
if (tagNames.length === 0) return defaultResult;
|
||||
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = statusContent.trim();
|
||||
|
||||
const lastChild = template.content.lastChild;
|
||||
|
||||
if (!lastChild) return defaultResult;
|
||||
|
||||
template.content.removeChild(lastChild);
|
||||
const contentWithoutLastLine = template;
|
||||
|
||||
// First, try to parse
|
||||
const contentHashtags = Array.from(
|
||||
contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'),
|
||||
).reduce<string[]>((result, link) => {
|
||||
if (isNodeLinkHashtag(link)) {
|
||||
if (link.textContent) result.push(normalizeHashtag(link.textContent));
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// Now we parse the last line, and try to see if it only contains hashtags
|
||||
const lastLineHashtags: string[] = [];
|
||||
// try to see if the last line is only hashtags
|
||||
let onlyHashtags = true;
|
||||
|
||||
const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));
|
||||
|
||||
Array.from(lastChild.childNodes).forEach((node) => {
|
||||
if (isNodeLinkHashtag(node) && node.textContent) {
|
||||
const normalized = normalizeHashtag(node.textContent);
|
||||
|
||||
if (!localeAwareInclude(normalizedTagNames, normalized)) {
|
||||
// stop here, this is not a real hashtag, so consider it as text
|
||||
onlyHashtags = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localeAwareInclude(contentHashtags, normalized))
|
||||
// only add it if it does not appear in the rest of the content
|
||||
lastLineHashtags.push(normalized);
|
||||
} else if (node.nodeType !== Node.TEXT_NODE || node.nodeValue?.trim()) {
|
||||
// not a space
|
||||
onlyHashtags = false;
|
||||
}
|
||||
});
|
||||
|
||||
const hashtagsInBar = tagNames.filter((tag) => {
|
||||
const normalizedTag = tag.normalize('NFKC');
|
||||
// the tag does not appear at all in the status content, it is an out-of-band tag
|
||||
return (
|
||||
!localeAwareInclude(contentHashtags, normalizedTag) &&
|
||||
!localeAwareInclude(lastLineHashtags, normalizedTag)
|
||||
);
|
||||
});
|
||||
|
||||
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
|
||||
const hasMedia = status.get('media_attachments').size > 0;
|
||||
const hasSpoiler = !!status.get('spoiler_text');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- due to https://github.com/microsoft/TypeScript/issues/9998
|
||||
if (onlyHashtags && ((hasMedia && !hasSpoiler) || !isOnlyOneLine)) {
|
||||
// if the last line only contains hashtags, and we either:
|
||||
// - have other content in the status
|
||||
// - dont have other content, but a media and no CW. If it has a CW, then we do not remove the content to avoid having an empty content behind the CW button
|
||||
statusContent = contentWithoutLastLine.innerHTML;
|
||||
// and add the tags to the bar
|
||||
hashtagsInBar.push(...lastLineHashtags);
|
||||
}
|
||||
|
||||
return {
|
||||
statusContentProps: { statusContent },
|
||||
hashtagsInBar: uniqueHashtagsWithCaseHandling(hashtagsInBar),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will process a status to, at the same time (avoiding parsing it twice):
|
||||
* - build the HashtagBar for this status
|
||||
* - remove the last-line hashtags from the status content
|
||||
* @param status The status to process
|
||||
* @returns Props to be passed to the <StatusContent> component, and the hashtagBar to render
|
||||
*/
|
||||
export function getHashtagBarForStatus(status: StatusLike) {
|
||||
const { statusContentProps, hashtagsInBar } =
|
||||
computeHashtagBarForStatus(status);
|
||||
|
||||
return {
|
||||
statusContentProps,
|
||||
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
|
||||
};
|
||||
}
|
||||
|
||||
const HashtagBar: React.FC<{
|
||||
hashtags: string[];
|
||||
}> = ({ hashtags }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const handleClick = useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
if (hashtags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const revealedHashtags = expanded
|
||||
? hashtags
|
||||
: hashtags.slice(0, VISIBLE_HASHTAGS - 1);
|
||||
|
||||
return (
|
||||
<div className='hashtag-bar'>
|
||||
{revealedHashtags.map((hashtag) => (
|
||||
<Link key={hashtag} to={`/tags/${hashtag}`}>
|
||||
#<span>{hashtag}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{!expanded && hashtags.length > VISIBLE_HASHTAGS && (
|
||||
<button className='link-button' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='hashtags.and_other'
|
||||
defaultMessage='…and {count, plural, other {# more}}'
|
||||
values={{ count: hashtags.length - VISIBLE_HASHTAGS }}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -22,6 +22,7 @@ import { displayMedia, visibleReactions } from '../initial_state';
|
||||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { DisplayName } from './display_name';
|
||||
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusReactions from './status_reactions';
|
||||
@ -549,6 +550,9 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden')
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
|
||||
@ -576,11 +580,12 @@ class Status extends ImmutablePureComponent {
|
||||
<StatusContent
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
expanded={!status.get('hidden')}
|
||||
expanded={expanded}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{media}
|
||||
@ -594,6 +599,8 @@ class Status extends ImmutablePureComponent {
|
||||
canReact={this.context.identity.signedIn}
|
||||
/>
|
||||
|
||||
{expanded && hashtagBar}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,6 +15,15 @@ import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_s
|
||||
|
||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {any} status
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getStatusContent(status) {
|
||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||
}
|
||||
|
||||
class TranslateButton extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
@ -65,6 +74,7 @@ class StatusContent extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
statusContent: PropTypes.string,
|
||||
expanded: PropTypes.bool,
|
||||
onExpandedToggle: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
@ -225,7 +235,7 @@ class StatusContent extends PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, intl } = this.props;
|
||||
const { status, intl, statusContent } = this.props;
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
@ -233,7 +243,7 @@ class StatusContent extends PureComponent {
|
||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||
const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') };
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
|
@ -265,9 +265,9 @@ class Header extends ImmutablePureComponent {
|
||||
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
|
||||
actionBtn = '';
|
||||
} else if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = <Button className={classNames({ 'button--with-bell': bellBtn !== '' })} 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'])) {
|
||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
|
||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
|
||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||
actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
|
||||
}
|
||||
|
@ -108,10 +108,10 @@ class Results extends PureComponent {
|
||||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results'>
|
||||
|
@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import EditedTimestamp from 'mastodon/components/edited_timestamp';
|
||||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import StatusReactions from 'mastodon/components/status_reactions';
|
||||
@ -295,6 +296,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden')
|
||||
|
||||
return (
|
||||
<div style={outerStyle}>
|
||||
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
|
||||
@ -314,6 +318,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
expanded={!status.get('hidden')}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
{media}
|
||||
@ -326,6 +331,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
canReact={this.context.identity.signedIn}
|
||||
/>
|
||||
|
||||
{expanded && hashtagBar}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
|
||||
|
@ -583,7 +583,7 @@ class Status extends ImmutablePureComponent {
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType='thread'
|
||||
previousId={i > 0 && list.get(i - 1)}
|
||||
previousId={i > 0 ? list.get(i - 1) : undefined}
|
||||
nextId={list.get(i + 1) || (ancestors && statusId)}
|
||||
rootId={statusId}
|
||||
/>
|
||||
|
@ -115,7 +115,10 @@ export default class ModalRoot extends PureComponent {
|
||||
{visible && (
|
||||
<>
|
||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
||||
{(SpecificComponent) => {
|
||||
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
|
||||
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
|
||||
}}
|
||||
</BundleContainer>
|
||||
|
||||
<Helmet>
|
||||
|
@ -62,7 +62,7 @@ class ReportModal extends ImmutablePureComponent {
|
||||
dispatch(submitReport({
|
||||
account_id: accountId,
|
||||
status_ids: selectedStatusIds.toArray(),
|
||||
selected_domains: selectedDomains.toArray(),
|
||||
forward_to_domains: selectedDomains.toArray(),
|
||||
comment,
|
||||
forward: selectedDomains.size > 0,
|
||||
category,
|
||||
|
@ -300,6 +300,7 @@
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
|
||||
"hashtag.follow": "Follow hashtag",
|
||||
"hashtag.unfollow": "Unfollow hashtag",
|
||||
"hashtags.and_other": "…and {count, plural, other {# more}}",
|
||||
"home.actions.go_to_explore": "See what's trending",
|
||||
"home.actions.go_to_suggestions": "Find people to follow",
|
||||
"home.column_settings.basic": "Basic",
|
||||
@ -534,6 +535,7 @@
|
||||
"reply_indicator.cancel": "Cancel",
|
||||
"report.block": "Block",
|
||||
"report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
|
||||
"report.categories.legal": "Legal",
|
||||
"report.categories.other": "Other",
|
||||
"report.categories.spam": "Spam",
|
||||
"report.categories.violation": "Content violates one or more server rules",
|
||||
|
@ -48,201 +48,198 @@ function loaded() {
|
||||
};
|
||||
};
|
||||
|
||||
ready(() => {
|
||||
const locale = document.documentElement.lang;
|
||||
const locale = document.documentElement.lang;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeFormat: false,
|
||||
});
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: 'short',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const formatMessage = ({ id, defaultMessage }, values) => {
|
||||
const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
|
||||
return messageFormat.format(values);
|
||||
};
|
||||
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const formattedDate = dateTimeFormat.format(datetime);
|
||||
|
||||
content.title = formattedDate;
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
|
||||
const isToday = date => {
|
||||
const today = new Date();
|
||||
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
|
||||
let formattedContent;
|
||||
|
||||
if (isToday(datetime)) {
|
||||
const formattedTime = timeFormat.format(datetime);
|
||||
|
||||
formattedContent = todayFormat.format({ time: formattedTime });
|
||||
} else {
|
||||
formattedContent = dateFormat.format(datetime);
|
||||
}
|
||||
|
||||
content.title = formattedContent;
|
||||
content.textContent = formattedContent;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const now = new Date();
|
||||
|
||||
const timeGiven = content.getAttribute('datetime').includes('T');
|
||||
content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
|
||||
content.textContent = timeAgoString({
|
||||
formatMessage,
|
||||
formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
|
||||
}, datetime, now, now.getFullYear(), timeGiven);
|
||||
});
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
|
||||
if (reactComponents.length > 0) {
|
||||
import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
|
||||
.then(({ default: MediaContainer }) => {
|
||||
[].forEach.call(reactComponents, (component) => {
|
||||
[].forEach.call(component.children, (child) => {
|
||||
component.removeChild(child);
|
||||
});
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
|
||||
const root = createRoot(content);
|
||||
root.render(<MediaContainer locale={locale} components={reactComponents} />);
|
||||
document.body.appendChild(content);
|
||||
scrollToDetailedStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
scrollToDetailedStatus();
|
||||
});
|
||||
} else {
|
||||
scrollToDetailedStatus();
|
||||
}
|
||||
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
|
||||
const username = document.getElementById('user_account_attributes_username');
|
||||
|
||||
if (username.value && username.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
|
||||
username.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}).catch(() => {
|
||||
username.setCustomValidity('');
|
||||
});
|
||||
} else {
|
||||
username.setCustomValidity('');
|
||||
}
|
||||
}, 500, { leading: false, trailing: true }));
|
||||
|
||||
delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
|
||||
const password = document.getElementById('user_password');
|
||||
const confirmation = document.getElementById('user_password_confirmation');
|
||||
if (!confirmation) return;
|
||||
|
||||
if (confirmation.value && confirmation.value.length > password.maxLength) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
|
||||
} else if (password.value && password.value !== confirmation.value) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
|
||||
} else {
|
||||
confirmation.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
|
||||
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '.status__content__spoiler-link', 'click', function() {
|
||||
const statusEl = this.parentNode.parentNode;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode.parentNode;
|
||||
const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
|
||||
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
|
||||
});
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const sidebar = document.querySelector('.sidebar ul');
|
||||
const toggleButton = document.querySelector('.sidebar__toggle__icon');
|
||||
const dateFormat = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeFormat: false,
|
||||
});
|
||||
|
||||
if (sidebar.classList.contains('visible')) {
|
||||
document.body.style.overflow = null;
|
||||
toggleButton.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
document.body.style.overflow = 'hidden';
|
||||
toggleButton.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
const timeFormat = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: 'short',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
toggleButton.classList.toggle('active');
|
||||
sidebar.classList.toggle('visible');
|
||||
const formatMessage = ({ id, defaultMessage }, values) => {
|
||||
const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
|
||||
return messageFormat.format(values);
|
||||
};
|
||||
|
||||
delegate(document, '.sidebar__toggle__icon', 'click', () => {
|
||||
toggleSidebar();
|
||||
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
|
||||
content.innerHTML = emojify(content.innerHTML);
|
||||
});
|
||||
|
||||
delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const formattedDate = dateTimeFormat.format(datetime);
|
||||
|
||||
content.title = formattedDate;
|
||||
content.textContent = formattedDate;
|
||||
});
|
||||
|
||||
const isToday = date => {
|
||||
const today = new Date();
|
||||
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
|
||||
let formattedContent;
|
||||
|
||||
if (isToday(datetime)) {
|
||||
const formattedTime = timeFormat.format(datetime);
|
||||
|
||||
formattedContent = todayFormat.format({ time: formattedTime });
|
||||
} else {
|
||||
formattedContent = dateFormat.format(datetime);
|
||||
}
|
||||
|
||||
content.title = formattedContent;
|
||||
content.textContent = formattedContent;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
|
||||
const datetime = new Date(content.getAttribute('datetime'));
|
||||
const now = new Date();
|
||||
|
||||
const timeGiven = content.getAttribute('datetime').includes('T');
|
||||
content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
|
||||
content.textContent = timeAgoString({
|
||||
formatMessage,
|
||||
formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
|
||||
}, datetime, now, now.getFullYear(), timeGiven);
|
||||
});
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
|
||||
if (reactComponents.length > 0) {
|
||||
import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container')
|
||||
.then(({ default: MediaContainer }) => {
|
||||
[].forEach.call(reactComponents, (component) => {
|
||||
[].forEach.call(component.children, (child) => {
|
||||
component.removeChild(child);
|
||||
});
|
||||
});
|
||||
|
||||
const content = document.createElement('div');
|
||||
|
||||
const root = createRoot(content);
|
||||
root.render(<MediaContainer locale={locale} components={reactComponents} />);
|
||||
document.body.appendChild(content);
|
||||
scrollToDetailedStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
scrollToDetailedStatus();
|
||||
});
|
||||
} else {
|
||||
scrollToDetailedStatus();
|
||||
}
|
||||
|
||||
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
|
||||
const username = document.getElementById('user_account_attributes_username');
|
||||
|
||||
if (username.value && username.value.length > 0) {
|
||||
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
|
||||
username.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}).catch(() => {
|
||||
username.setCustomValidity('');
|
||||
});
|
||||
} else {
|
||||
username.setCustomValidity('');
|
||||
}
|
||||
}, 500, { leading: false, trailing: true }));
|
||||
|
||||
delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
|
||||
const password = document.getElementById('user_password');
|
||||
const confirmation = document.getElementById('user_password_confirmation');
|
||||
if (!confirmation) return;
|
||||
|
||||
if (confirmation.value && confirmation.value.length > password.maxLength) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
|
||||
} else if (password.value && password.value !== confirmation.value) {
|
||||
confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
|
||||
} else {
|
||||
confirmation.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
|
||||
const field = document.getElementById(id);
|
||||
if (field) {
|
||||
field.value = '';
|
||||
}
|
||||
});
|
||||
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||
|
||||
delegate(document, '.status__content__spoiler-link', 'click', function() {
|
||||
const statusEl = this.parentNode.parentNode;
|
||||
|
||||
if (statusEl.dataset.spoiler === 'expanded') {
|
||||
statusEl.dataset.spoiler = 'folded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
|
||||
} else {
|
||||
statusEl.dataset.spoiler = 'expanded';
|
||||
this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
|
||||
const statusEl = spoilerLink.parentNode.parentNode;
|
||||
const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
|
||||
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
|
||||
});
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const sidebar = document.querySelector('.sidebar ul');
|
||||
const toggleButton = document.querySelector('.sidebar__toggle__icon');
|
||||
|
||||
if (sidebar.classList.contains('visible')) {
|
||||
document.body.style.overflow = null;
|
||||
toggleButton.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
document.body.style.overflow = 'hidden';
|
||||
toggleButton.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
toggleButton.classList.toggle('active');
|
||||
sidebar.classList.toggle('visible');
|
||||
};
|
||||
|
||||
delegate(document, '.sidebar__toggle__icon', 'click', () => {
|
||||
toggleSidebar();
|
||||
});
|
||||
|
||||
delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
|
||||
const field = document.getElementById(id);
|
||||
if (field) {
|
||||
field.value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function main() {
|
||||
ready(loaded);
|
||||
|
@ -170,11 +170,6 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-multiple-columns &.button--with-bell {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.column__wrapper {
|
||||
@ -1117,7 +1112,8 @@ body > [data-popper-placement] {
|
||||
.audio-player,
|
||||
.attachment-list,
|
||||
.picture-in-picture-placeholder,
|
||||
.status-card {
|
||||
.status-card,
|
||||
.hashtag-bar {
|
||||
margin-inline-start: $thread-margin;
|
||||
width: calc(100% - ($thread-margin));
|
||||
}
|
||||
@ -5148,6 +5144,7 @@ a.status-card {
|
||||
|
||||
&.active {
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@ -8562,6 +8559,44 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
&__choices {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
&__choice {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $darker-text-color;
|
||||
margin-bottom: 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint - 1px) {
|
||||
&__choices {
|
||||
flex-direction: column;
|
||||
|
||||
&__choice {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-button {
|
||||
font-size: inherit;
|
||||
display: inline;
|
||||
@ -9280,3 +9315,25 @@ noscript {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hashtag-bar {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
gap: 4px;
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
color: $dark-text-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -309,9 +309,19 @@ code {
|
||||
border-radius: 4px;
|
||||
background: url('images/void.png');
|
||||
|
||||
&[src$='missing.png'] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&#account_avatar-preview {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
include FormattingHelper
|
||||
|
||||
def perform
|
||||
@account.schedule_refresh_if_stale!
|
||||
|
||||
dereference_object!
|
||||
|
||||
case @object['type']
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
def perform
|
||||
@account.schedule_refresh_if_stale!
|
||||
|
||||
dereference_object!
|
||||
|
||||
if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))
|
||||
|
@ -1,6 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
INDEXES = [
|
||||
InstancesIndex,
|
||||
AccountsIndex,
|
||||
TagsIndex,
|
||||
StatusesIndex,
|
||||
].freeze
|
||||
|
||||
def skip?
|
||||
!current_user.can?(:view_devops)
|
||||
end
|
||||
@ -8,11 +15,15 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
def pass?
|
||||
return true unless Chewy.enabled?
|
||||
|
||||
running_version.present? && compatible_version?
|
||||
running_version.present? && compatible_version? && cluster_health['status'] == 'green' && indexes_match? && preset_matches?
|
||||
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
|
||||
false
|
||||
end
|
||||
|
||||
def message
|
||||
if running_version.present?
|
||||
if running_version.blank?
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_running_check)
|
||||
elsif !compatible_version?
|
||||
Admin::SystemCheck::Message.new(
|
||||
:elasticsearch_version_check,
|
||||
I18n.t(
|
||||
@ -21,13 +32,32 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
required_version: required_version
|
||||
)
|
||||
)
|
||||
elsif !indexes_match?
|
||||
Admin::SystemCheck::Message.new(
|
||||
:elasticsearch_index_mismatch,
|
||||
mismatched_indexes.join(' ')
|
||||
)
|
||||
elsif cluster_health['status'] == 'red'
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_health_red)
|
||||
elsif cluster_health['number_of_nodes'] < 2 && es_preset != 'single_node_cluster'
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_preset_single_node, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling')
|
||||
elsif Chewy.client.indices.get_settings[Chewy::Stash::Specification.index_name]&.dig('settings', 'index', 'number_of_replicas')&.to_i&.positive? && es_preset == 'single_node_cluster'
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_reset_chewy)
|
||||
elsif cluster_health['status'] == 'yellow'
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_health_yellow)
|
||||
else
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_running_check)
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_preset, nil, 'https://docs.joinmastodon.org/admin/optional/elasticsearch/#scaling')
|
||||
end
|
||||
rescue Faraday::ConnectionFailed, Elasticsearch::Transport::Transport::Error
|
||||
Admin::SystemCheck::Message.new(:elasticsearch_running_check)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cluster_health
|
||||
@cluster_health ||= Chewy.client.cluster.health
|
||||
end
|
||||
|
||||
def running_version
|
||||
@running_version ||= begin
|
||||
Chewy.client.info['version']['number']
|
||||
@ -49,5 +79,30 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
|
||||
|
||||
Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
|
||||
Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
|
||||
def mismatched_indexes
|
||||
@mismatched_indexes ||= INDEXES.filter_map do |klass|
|
||||
klass.index_name if Chewy.client.indices.get_mapping[klass.index_name]&.deep_symbolize_keys != klass.mappings_hash
|
||||
end
|
||||
end
|
||||
|
||||
def indexes_match?
|
||||
mismatched_indexes.empty?
|
||||
end
|
||||
|
||||
def es_preset
|
||||
ENV.fetch('ES_PRESET', 'single_node_cluster')
|
||||
end
|
||||
|
||||
def preset_matches?
|
||||
case es_preset
|
||||
when 'single_node_cluster'
|
||||
cluster_health['number_of_nodes'] == 1
|
||||
else
|
||||
cluster_health['number_of_nodes'] > 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,8 +2,14 @@
|
||||
|
||||
class CacheBuster
|
||||
def initialize(options = {})
|
||||
@secret_header = options[:secret_header] || 'Secret-Header'
|
||||
@secret = options[:secret] || 'True'
|
||||
ActiveSupport::Deprecation.warn('Default values for the cache buster secret header name and values will be removed in Mastodon 4.3. Please set them explicitely if you rely on those.') unless options[:http_method] || (options[:secret] && options[:secret_header])
|
||||
|
||||
@secret_header = options[:secret_header] ||
|
||||
(options[:http_method] ? nil : 'Secret-Header')
|
||||
@secret = options[:secret] ||
|
||||
(options[:http_method] ? nil : 'True')
|
||||
|
||||
@http_method = options[:http_method] || 'GET'
|
||||
end
|
||||
|
||||
def bust(url)
|
||||
@ -21,8 +27,9 @@ class CacheBuster
|
||||
end
|
||||
|
||||
def build_request(url, http_client)
|
||||
Request.new(:get, url, http_client: http_client).tap do |request|
|
||||
request.add_headers(@secret_header => @secret)
|
||||
end
|
||||
request = Request.new(@http_method.downcase.to_sym, url, http_client: http_client)
|
||||
request.add_headers(@secret_header => @secret) if @secret_header.present? && @secret && !@secret.empty?
|
||||
|
||||
request
|
||||
end
|
||||
end
|
||||
|
@ -68,8 +68,8 @@ class Importer::BaseImporter
|
||||
|
||||
protected
|
||||
|
||||
def in_work_unit(*args, &block)
|
||||
work_unit = Concurrent::Promises.future_on(@executor, *args, &block)
|
||||
def in_work_unit(...)
|
||||
work_unit = Concurrent::Promises.future_on(@executor, ...)
|
||||
|
||||
work_unit.on_fulfillment!(&@on_progress)
|
||||
work_unit.on_rejection!(&@on_failure)
|
||||
|
@ -117,7 +117,7 @@ class Request
|
||||
|
||||
def perform
|
||||
begin
|
||||
response = http_client.public_send(@verb, @url.to_s, @options.merge(headers: headers))
|
||||
response = http_client.request(@verb, @url.to_s, @options.merge(headers: headers))
|
||||
rescue => e
|
||||
raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
|
||||
end
|
||||
|
@ -43,6 +43,9 @@ class VideoMetadataExtractor
|
||||
@height = video_stream[:height]
|
||||
@frame_rate = video_stream[:avg_frame_rate] == '0/0' ? nil : Rational(video_stream[:avg_frame_rate])
|
||||
@r_frame_rate = video_stream[:r_frame_rate] == '0/0' ? nil : Rational(video_stream[:r_frame_rate])
|
||||
# For some video streams the frame_rate reported by `ffprobe` will be 0/0, but for these streams we
|
||||
# should use `r_frame_rate` instead. Video screencast generated by Gnome Screencast have this issue.
|
||||
@frame_rate ||= @r_frame_rate
|
||||
end
|
||||
|
||||
if (audio_stream = audio_streams.first)
|
||||
|
@ -50,6 +50,7 @@
|
||||
# trendable :boolean
|
||||
# reviewed_at :datetime
|
||||
# requested_review_at :datetime
|
||||
# indexable :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
class Account < ApplicationRecord
|
||||
@ -62,6 +63,8 @@ class Account < ApplicationRecord
|
||||
trust_level
|
||||
)
|
||||
|
||||
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
|
||||
|
||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i
|
||||
MENTION_RE = %r{(?<=^|[^/[:word:]])@((#{USERNAME_RE})(?:@[[:word:].-]+[[:word:]]+)?)}i
|
||||
URL_PREFIX_RE = %r{\Ahttp(s?)://[^/]+}
|
||||
@ -212,6 +215,12 @@ class Account < ApplicationRecord
|
||||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||
end
|
||||
|
||||
def schedule_refresh_if_stale!
|
||||
return unless last_webfingered_at.present? && last_webfingered_at <= BACKGROUND_REFRESH_INTERVAL.ago
|
||||
|
||||
AccountRefreshWorker.perform_in(rand(6.hours.to_i), id)
|
||||
end
|
||||
|
||||
def refresh!
|
||||
ResolveAccountService.new.call(acct) unless local?
|
||||
end
|
||||
@ -439,8 +448,21 @@ class Account < ApplicationRecord
|
||||
EntityCache.instance.mention(username, domain)
|
||||
end
|
||||
end
|
||||
|
||||
def inverse_alias(key, original_key)
|
||||
define_method("#{key}=") do |value|
|
||||
public_send("#{original_key}=", !ActiveModel::Type::Boolean.new.cast(value))
|
||||
end
|
||||
|
||||
define_method(key) do
|
||||
!public_send(original_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
inverse_alias :show_collections, :hide_collections
|
||||
inverse_alias :unlocked, :locked
|
||||
|
||||
def emojis
|
||||
@emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
|
||||
end
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: follow_recommendations
|
||||
# Table name: global_follow_recommendations
|
||||
#
|
||||
# account_id :bigint(8) primary key
|
||||
# rank :decimal(, )
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
class FollowRecommendation < ApplicationRecord
|
||||
self.primary_key = :account_id
|
||||
self.table_name = :global_follow_recommendations
|
||||
|
||||
belongs_to :account_summary, foreign_key: :account_id, inverse_of: false
|
||||
belongs_to :account
|
||||
|
@ -51,11 +51,11 @@ class PublicFeed
|
||||
end
|
||||
|
||||
def local_only?
|
||||
options[:local]
|
||||
options[:local] && !options[:remote]
|
||||
end
|
||||
|
||||
def remote_only?
|
||||
options[:remote]
|
||||
options[:remote] && !options[:local]
|
||||
end
|
||||
|
||||
def account?
|
||||
|
@ -48,7 +48,10 @@ class Report < ApplicationRecord
|
||||
|
||||
validate :validate_rule_ids
|
||||
|
||||
# entries here needs to be kept in sync with app/javascript/mastodon/features/notifications/components/report.jsx
|
||||
# entries here need to be kept in sync with the front-end:
|
||||
# - app/javascript/mastodon/features/notifications/components/report.jsx
|
||||
# - app/javascript/mastodon/features/report/category.jsx
|
||||
# - app/javascript/mastodon/components/admin/ReportReasonSelector.jsx
|
||||
enum category: {
|
||||
other: 0,
|
||||
spam: 1_000,
|
||||
|
@ -426,13 +426,25 @@ class Status < ApplicationRecord
|
||||
|
||||
account_ids.uniq!
|
||||
|
||||
status_ids = cached_items.map { |item| item.reblog? ? item.reblog_of_id : item.id }.uniq
|
||||
|
||||
return if account_ids.empty?
|
||||
|
||||
accounts = Account.where(id: account_ids).includes(:account_stat, :user).index_by(&:id)
|
||||
|
||||
status_stats = StatusStat.where(status_id: status_ids).index_by(&:status_id)
|
||||
|
||||
cached_items.each do |item|
|
||||
item.account = accounts[item.account_id]
|
||||
item.reblog.account = accounts[item.reblog.account_id] if item.reblog?
|
||||
|
||||
if item.reblog?
|
||||
status_stat = status_stats[item.reblog.id]
|
||||
item.reblog.status_stat = status_stat if status_stat.present?
|
||||
else
|
||||
status_stat = status_stats[item.id]
|
||||
item.status_stat = status_stat if status_stat.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -20,6 +20,9 @@ class UserSettings
|
||||
setting :hide_followers_count, default: false
|
||||
setting :visible_reactions, default: 6
|
||||
|
||||
setting_inverse_alias :indexable, :noindex
|
||||
setting_inverse_alias :show_followers_count, :hide_followers_count
|
||||
|
||||
namespace :web do
|
||||
setting :advanced_layout, default: false
|
||||
setting :trends, default: true
|
||||
@ -63,31 +66,26 @@ class UserSettings
|
||||
end
|
||||
|
||||
def [](key)
|
||||
key = key.to_sym
|
||||
definition = self.class.definition_for(key)
|
||||
|
||||
raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
|
||||
raise KeyError, "Undefined setting: #{key}" if definition.nil?
|
||||
|
||||
if @original_hash.key?(key)
|
||||
@original_hash[key]
|
||||
else
|
||||
self.class.definition_for(key).default_value
|
||||
end
|
||||
definition.value_for(key, @original_hash[definition.key])
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
key = key.to_sym
|
||||
definition = self.class.definition_for(key)
|
||||
|
||||
raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
|
||||
raise KeyError, "Undefined setting: #{key}" if definition.nil?
|
||||
|
||||
setting_definition = self.class.definition_for(key)
|
||||
typecast_value = setting_definition.type_cast(value)
|
||||
typecast_value = definition.type_cast(value)
|
||||
|
||||
raise ArgumentError, "Invalid value for setting #{key}: #{typecast_value}" if setting_definition.in.present? && setting_definition.in.exclude?(typecast_value)
|
||||
raise ArgumentError, "Invalid value for setting #{definition.key}: #{typecast_value}" if definition.in.present? && definition.in.exclude?(typecast_value)
|
||||
|
||||
if typecast_value.nil?
|
||||
@original_hash.delete(key)
|
||||
@original_hash.delete(definition.key)
|
||||
else
|
||||
@original_hash[key] = typecast_value
|
||||
@original_hash[definition.key] = definition.value_for(key, typecast_value)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -10,6 +10,10 @@ module UserSettings::DSL
|
||||
end
|
||||
end
|
||||
|
||||
def setting_inverse_alias(key, original_key)
|
||||
@definitions[key] = @definitions[original_key].inverse_of(key)
|
||||
end
|
||||
|
||||
def namespace(key, &block)
|
||||
@definitions ||= {}
|
||||
|
||||
|
@ -10,6 +10,27 @@ class UserSettings::Setting
|
||||
@in = options[:in]
|
||||
end
|
||||
|
||||
def inverse_of(name)
|
||||
@inverse_of = name.to_sym
|
||||
self
|
||||
end
|
||||
|
||||
def value_for(name, original_value)
|
||||
value = begin
|
||||
if original_value.nil?
|
||||
default_value
|
||||
else
|
||||
original_value
|
||||
end
|
||||
end
|
||||
|
||||
if !@inverse_of.nil? && @inverse_of == name.to_sym
|
||||
!value
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def default_value
|
||||
if @default_value.respond_to?(:call)
|
||||
@default_value.call
|
||||
|
@ -7,13 +7,14 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
||||
context :security
|
||||
|
||||
context_extensions :manually_approves_followers, :featured, :also_known_as,
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended
|
||||
:moved_to, :property_value, :discoverable, :olm, :suspended,
|
||||
:memorial
|
||||
|
||||
attributes :id, :type, :following, :followers,
|
||||
:inbox, :outbox, :featured, :featured_tags,
|
||||
:preferred_username, :name, :summary,
|
||||
:url, :manually_approves_followers,
|
||||
:discoverable, :published
|
||||
:discoverable, :published, :memorial
|
||||
|
||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
||||
|
||||
|
@ -124,7 +124,7 @@ class AccountSearchService < BaseService
|
||||
multi_match: {
|
||||
query: @query,
|
||||
type: 'bool_prefix',
|
||||
fields: %w(username username.* display_name display_name.*),
|
||||
fields: %w(username^2 username.*^2 display_name display_name.*),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
@ -115,6 +115,8 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
@account.fields = property_values || {}
|
||||
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
|
||||
@account.discoverable = @json['discoverable'] || false
|
||||
@account.indexable = @json['indexable'] || false
|
||||
@account.memorial = @json['memorial'] || false
|
||||
end
|
||||
|
||||
def set_fetchable_key!
|
||||
|
@ -18,16 +18,21 @@
|
||||
= ff.input :'notification_emails.reblog', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.reblog')
|
||||
= ff.input :'notification_emails.favourite', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.favourite')
|
||||
= ff.input :'notification_emails.mention', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.mention')
|
||||
= ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports)
|
||||
= ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
|
||||
= ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
|
||||
= ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
|
||||
= ff.input :'notification_emails.link_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_link') if current_user.can?(:manage_taxonomies)
|
||||
= ff.input :'notification_emails.status_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_status') if current_user.can?(:manage_taxonomies)
|
||||
|
||||
.fields-group
|
||||
= ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails')
|
||||
|
||||
- if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies)
|
||||
%h4= t 'notifications.administration_emails'
|
||||
|
||||
.fields-group
|
||||
= ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports)
|
||||
= ff.input :'notification_emails.appeal', as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.appeal') if current_user.can?(:manage_appeals)
|
||||
= ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users)
|
||||
= ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies)
|
||||
= ff.input :'notification_emails.link_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_link') if current_user.can?(:manage_taxonomies)
|
||||
= ff.input :'notification_emails.status_trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_status') if current_user.can?(:manage_taxonomies)
|
||||
|
||||
%h4= t 'notifications.other_settings'
|
||||
|
||||
.fields-group
|
||||
|
@ -8,16 +8,9 @@
|
||||
= render 'shared/error_messages', object: current_user
|
||||
|
||||
= f.simple_fields_for :settings, current_user.settings do |ff|
|
||||
.fields-group
|
||||
= ff.input :noindex, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_noindex'), hint: I18n.t('simple_form.hints.defaults.setting_noindex')
|
||||
|
||||
.fields-group
|
||||
= ff.input :aggregate_reblogs, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_aggregate_reblogs'), hint: I18n.t('simple_form.hints.defaults.setting_aggregate_reblogs')
|
||||
|
||||
- unless Setting.hide_followers_count
|
||||
.fields-group
|
||||
= ff.input :hide_followers_count, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_hide_followers_count'), glitch_only: true
|
||||
|
||||
%h4= t 'preferences.posting_defaults'
|
||||
|
||||
.fields-row
|
||||
@ -30,9 +23,6 @@
|
||||
.fields-group
|
||||
= ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive')
|
||||
|
||||
.fields-group
|
||||
= ff.input :show_application, wrapper: :with_label, recommended: true, label: I18n.t('simple_form.labels.defaults.setting_show_application'), hint: I18n.t('simple_form.hints.defaults.setting_show_application')
|
||||
|
||||
.fields-group
|
||||
= ff.input :default_content_type, collection: ['text/plain', 'text/markdown', 'text/html'], wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_content_type'), include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.defaults.setting_default_content_type_#{item.split('/')[1]}"), content_tag(:span, t("simple_form.hints.defaults.setting_default_content_type_#{item.split('/')[1]}"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', glitch_only: true
|
||||
|
||||
|
47
app/views/settings/privacy/show.html.haml
Normal file
47
app/views/settings/privacy/show.html.haml
Normal file
@ -0,0 +1,47 @@
|
||||
- content_for :page_title do
|
||||
= t('privacy.title')
|
||||
|
||||
- content_for :heading do
|
||||
%h2= t('settings.profile')
|
||||
= render partial: 'settings/shared/profile_navigation'
|
||||
|
||||
= simple_form_for @account, url: settings_privacy_path, html: { method: :put } do |f|
|
||||
= render 'shared/error_messages', object: @account
|
||||
|
||||
%p.lead= t('privacy.hint_html')
|
||||
|
||||
%h4= t('privacy.reach')
|
||||
|
||||
%p.lead= t('privacy.reach_hint_html')
|
||||
|
||||
.fields-group
|
||||
= f.input :discoverable, as: :boolean, wrapper: :with_label, recommended: true
|
||||
|
||||
.fields-group
|
||||
= f.input :unlocked, as: :boolean, wrapper: :with_label
|
||||
|
||||
%h4= t('privacy.search')
|
||||
|
||||
%p.lead= t('privacy.search_hint_html')
|
||||
|
||||
= f.simple_fields_for :settings, current_user.settings do |ff|
|
||||
.fields-group
|
||||
= ff.input :indexable, wrapper: :with_label
|
||||
|
||||
%h4= t('privacy.privacy')
|
||||
|
||||
%p.lead= t('privacy.privacy_hint_html')
|
||||
|
||||
.fields-group
|
||||
= f.input :show_collections, as: :boolean, wrapper: :with_label
|
||||
|
||||
= f.simple_fields_for :settings, current_user.settings do |ff|
|
||||
- unless Setting.hide_followers_count
|
||||
.fields-group
|
||||
= ff.input :show_followers_count, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_show_followers_count'), hint: I18n.t('simple_form.hints.defaults.setting_show_followers_count'), glitch_only: true
|
||||
|
||||
.fields-group
|
||||
= ff.input :show_application, wrapper: :with_label
|
||||
|
||||
.actions
|
||||
= f.button :button, t('generic.save_changes'), type: :submit
|
@ -35,10 +35,10 @@
|
||||
.fields-group
|
||||
= f.input :avatar, wrapper: :with_block_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT))
|
||||
|
||||
- if @account.avatar.present?
|
||||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= image_tag @account.avatar.url, class: 'fields-group__thumbnail', width: 90, height: 90
|
||||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= image_tag @account.avatar.url, class: 'fields-group__thumbnail', id: 'account_avatar-preview'
|
||||
- if @account.avatar.present?
|
||||
= link_to settings_profile_picture_path('avatar'), data: { method: :delete }, class: 'link-button link-button--destructive' do
|
||||
= fa_icon 'trash fw'
|
||||
= t('generic.delete')
|
||||
@ -48,25 +48,14 @@
|
||||
.fields-group
|
||||
= f.input :header, wrapper: :with_block_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header', dimensions: '1500x500', size: number_to_human_size(AccountHeader::LIMIT))
|
||||
|
||||
- if @account.header.present?
|
||||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= image_tag @account.header.url, class: 'fields-group__thumbnail'
|
||||
.fields-row__column.fields-row__column-6
|
||||
.fields-group
|
||||
= image_tag @account.header.url, class: 'fields-group__thumbnail', id: 'account_header-preview'
|
||||
- if @account.header.present?
|
||||
= link_to settings_profile_picture_path('header'), data: { method: :delete }, class: 'link-button link-button--destructive' do
|
||||
= fa_icon 'trash fw'
|
||||
= t('generic.delete')
|
||||
|
||||
%h4= t('edit_profile.safety_and_privacy')
|
||||
|
||||
.fields-group
|
||||
= f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true
|
||||
|
||||
.fields-group
|
||||
= f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
|
||||
|
||||
.fields-group
|
||||
= f.input :hide_collections, as: :boolean, wrapper: :with_label, label: t('simple_form.labels.defaults.setting_hide_network'), hint: t('simple_form.hints.defaults.setting_hide_network')
|
||||
|
||||
%h4= t('edit_profile.other')
|
||||
|
||||
.fields-group
|
||||
|
@ -2,5 +2,6 @@
|
||||
= render_navigation renderer: :links do |primary|
|
||||
:ruby
|
||||
primary.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_path
|
||||
primary.item :privacy, safe_join([fa_icon('lock fw'), t('privacy.title')]), settings_privacy_path
|
||||
primary.item :verification, safe_join([fa_icon('check fw'), t('verification.verification')]), settings_verification_path
|
||||
primary.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_path
|
||||
|
14
app/workers/account_refresh_worker.rb
Normal file
14
app/workers/account_refresh_worker.rb
Normal file
@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AccountRefreshWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
|
||||
|
||||
def perform(account_id)
|
||||
account = Account.find_by(id: account_id)
|
||||
return if account.nil? || account.last_webfingered_at > Account::BACKGROUND_REFRESH_INTERVAL.ago
|
||||
|
||||
ResolveAccountService.new.call(account)
|
||||
end
|
||||
end
|
@ -51,6 +51,7 @@ require_relative '../lib/rails/engine_extensions'
|
||||
require_relative '../lib/active_record/database_tasks_extensions'
|
||||
require_relative '../lib/active_record/batches'
|
||||
require_relative '../lib/simple_navigation/item_extensions'
|
||||
require_relative '../lib/http_extensions'
|
||||
|
||||
Dotenv::Railtie.load
|
||||
|
||||
|
@ -6,5 +6,6 @@ Rails.application.configure do
|
||||
config.x.cache_buster = {
|
||||
secret_header: ENV['CACHE_BUSTER_SECRET_HEADER'],
|
||||
secret: ENV['CACHE_BUSTER_SECRET'],
|
||||
http_method: ENV['CACHE_BUSTER_HTTP_METHOD'] || 'GET',
|
||||
}
|
||||
end
|
||||
|
@ -15,6 +15,9 @@ Chewy.settings = {
|
||||
journal: false,
|
||||
user: user,
|
||||
password: password,
|
||||
index: {
|
||||
number_of_replicas: ['single_node_cluster', nil].include?(ENV['ES_PRESET'].presence) ? 0 : 1,
|
||||
},
|
||||
}
|
||||
|
||||
# We use our own async strategy even outside the request-response
|
||||
|
@ -8,7 +8,7 @@ en:
|
||||
setting_default_content_type_markdown: When writing toots, assume they are using Markdown for rich text formatting, unless specified otherwise
|
||||
setting_default_content_type_plain: When writing toots, assume they are plain text with no special formatting, unless specified otherwise (default Mastodon behavior)
|
||||
setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
|
||||
setting_hide_followers_count: Hide your followers count from everybody, including you. Some applications may display a negative followers count.
|
||||
setting_show_followers_count: Show your followers count on your profile. If you hide your followers count, it will be hidden even from yourself, and some applications may display a negative followers count.
|
||||
setting_skin: Reskins the selected Mastodon flavour
|
||||
labels:
|
||||
defaults:
|
||||
@ -17,7 +17,7 @@ en:
|
||||
setting_default_content_type_markdown: Markdown
|
||||
setting_default_content_type_plain: Plain text
|
||||
setting_favourite_modal: Show confirmation dialog before favouriting (applies to Glitch flavour only)
|
||||
setting_hide_followers_count: Hide your followers count
|
||||
setting_show_followers_count: Show your followers count
|
||||
setting_skin: Skin
|
||||
setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
|
||||
setting_visible_reactions: Number of visible emoji reactions
|
||||
|
@ -1152,7 +1152,6 @@ ar:
|
||||
edit_profile:
|
||||
basic_information: معلومات أساسية
|
||||
other: أخرى
|
||||
safety_and_privacy: الأمان والخصوصية
|
||||
errors:
|
||||
'400': الطلب الذي قدمته غير صالح أو أنّ شكله غير سليم.
|
||||
'403': ليس لك الصلاحيات الكافية لعرض هذه الصفحة.
|
||||
|
@ -528,8 +528,6 @@ ast:
|
||||
your_appeal_approved: Aprobóse la to apellación
|
||||
your_appeal_pending: Unviesti una apellación
|
||||
your_appeal_rejected: Refugóse la to apellación
|
||||
edit_profile:
|
||||
safety_and_privacy: Seguranza ya privacidá
|
||||
errors:
|
||||
'400': La solicitú qu'unviesti nun yera válida o yera incorreuta.
|
||||
'403': Nun tienes permisu pa ver esta páxina.
|
||||
|
@ -1176,7 +1176,6 @@ be:
|
||||
basic_information: Асноўная інфармацыя
|
||||
hint_html: "<strong>Наладзьце тое, што людзі будуць бачыць у вашым профілі і побач з вашымі паведамленнямі.</strong> Іншыя людзі з большай верагоднасцю будуць сачыць і ўзаемадзейнічаць з вамі, калі ў вас ёсць запоўнены профіль і фота профілю."
|
||||
other: Іншае
|
||||
safety_and_privacy: Бяспека і прыватнасць
|
||||
errors:
|
||||
'400': Запыт, які вы адправілі, памылковы або няправільны.
|
||||
'403': У вас няма дазволу на прагляд гэтай старонкі.
|
||||
|
@ -1139,7 +1139,6 @@ bg:
|
||||
basic_information: Основна информация
|
||||
hint_html: "<strong>Персонализирайте какво хората виждат в обществения ви профил и до публикациите ви.</strong> Другите хора са по-склонни да ви последват и да взаимодействат с вас, когато имате попълнен профил и снимка на профила."
|
||||
other: Друго
|
||||
safety_and_privacy: Безопасност и поверителност
|
||||
errors:
|
||||
'400': Подадохте невалидна или деформирана заявка.
|
||||
'403': Нямате позволение да разгледате тази страница.
|
||||
|
@ -1140,7 +1140,6 @@ ca:
|
||||
basic_information: Informació bàsica
|
||||
hint_html: "<strong>Personalitza el que la gent veu en el teu perfil públic i a prop dels teus tuts..</strong> És més probable que altres persones et segueixin i interaccionin amb tu quan tens emplenat el teu perfil i amb la teva imatge."
|
||||
other: Altres
|
||||
safety_and_privacy: Seguretat i privacitat
|
||||
errors:
|
||||
'400': La sol·licitud que vas emetre no era vàlida o no era correcta.
|
||||
'403': No tens permís per a veure aquesta pàgina.
|
||||
|
@ -1212,7 +1212,6 @@ cy:
|
||||
basic_information: Gwybodaeth Sylfaenol
|
||||
hint_html: "<strong>Addaswch yr hyn y mae pobl yn ei weld ar eich proffil cyhoeddus ac wrth ymyl eich postiadau.</strong> Mae pobl eraill yn fwy tebygol o'ch dilyn yn ôl a rhyngweithio â chi pan fydd gennych broffil wedi'i lenwi a llun proffil."
|
||||
other: Arall
|
||||
safety_and_privacy: Diogelwch a phreifatrwydd
|
||||
errors:
|
||||
'400': Roedd y cais a gyflwynwyd gennych yn annilys neu wedi'i gamffurfio.
|
||||
'403': Nid oes gennych ganiatâd i weld y dudalen hon.
|
||||
|
@ -1140,7 +1140,6 @@ da:
|
||||
basic_information: Oplysninger
|
||||
hint_html: "<strong>Tilpas hvad folk ser på din offentlige profil og ved siden af dine indlæg.</strong> Andre personer vil mere sandsynligt følge dig tilbage og interagere med dig, når du har en udfyldt profil og et profilbillede."
|
||||
other: Andre
|
||||
safety_and_privacy: Sikkerhed og privatliv
|
||||
errors:
|
||||
'400': Din indsendte anmodning er ugyldig eller fejlbehæftet.
|
||||
'403': Du har ikke tilladelse til at se denne side.
|
||||
|
@ -1140,7 +1140,6 @@ de:
|
||||
basic_information: Allgemeine Informationen
|
||||
hint_html: "<strong>Bestimme, was andere auf deinem öffentlichen Profil und neben deinen Beiträgen sehen können.</strong> Wenn du ein Profilbild festlegst und dein Profil vervollständigst, werden andere eher mit dir interagieren und dir folgen."
|
||||
other: Andere
|
||||
safety_and_privacy: Sicherheit und Datenschutz
|
||||
errors:
|
||||
'400': Die Anfrage, die du gestellt hast, war ungültig oder fehlerhaft.
|
||||
'403': Dir fehlt die Berechtigung, diese Seite aufzurufen.
|
||||
|
@ -1140,7 +1140,6 @@ en-GB:
|
||||
basic_information: Basic information
|
||||
hint_html: "<strong>Customise what people see on your public profile and next to your posts.</strong> Other people are more likely to follow you back and interact with you when you have a filled out profile and a profile picture."
|
||||
other: Other
|
||||
safety_and_privacy: Safety and privacy
|
||||
errors:
|
||||
'400': The request you submitted was invalid or malformed.
|
||||
'403': You don't have permission to view this page.
|
||||
|
@ -814,6 +814,20 @@ en:
|
||||
system_checks:
|
||||
database_schema_check:
|
||||
message_html: There are pending database migrations. Please run them to ensure the application behaves as expected
|
||||
elasticsearch_health_red:
|
||||
message_html: Elasticsearch cluster is unhealthy (red status), search features are unavailable
|
||||
elasticsearch_health_yellow:
|
||||
message_html: Elasticsearch cluster is unhealthy (yellow status), you may want to investigate the reason
|
||||
elasticsearch_index_mismatch:
|
||||
message_html: Elasticsearch index mappings are outdated. Please run <code>tootctl search deploy --only=%{value}</code>
|
||||
elasticsearch_preset:
|
||||
action: See documentation
|
||||
message_html: Your Elasticsearch cluster has more than one node, but Mastodon is not configured to use them.
|
||||
elasticsearch_preset_single_node:
|
||||
action: See documentation
|
||||
message_html: Your Elasticsearch cluster has only one node, <code>ES_PRESET</code> should be set to <code>single_node_cluster</code>.
|
||||
elasticsearch_reset_chewy:
|
||||
message_html: Your Elasticsearch system index is outdated due to a setting change. Please run <code>tootctl search deploy --reset-chewy</code> to update it.
|
||||
elasticsearch_running_check:
|
||||
message_html: Could not connect to Elasticsearch. Please check that it is running, or disable full-text search
|
||||
elasticsearch_version_check:
|
||||
@ -1140,7 +1154,6 @@ en:
|
||||
basic_information: Basic information
|
||||
hint_html: "<strong>Customize what people see on your public profile and next to your posts.</strong> Other people are more likely to follow you back and interact with you when you have a filled out profile and a profile picture."
|
||||
other: Other
|
||||
safety_and_privacy: Safety and privacy
|
||||
errors:
|
||||
'400': The request you submitted was invalid or malformed.
|
||||
'403': You don't have permission to view this page.
|
||||
@ -1433,6 +1446,7 @@ en:
|
||||
update:
|
||||
subject: "%{name} edited a post"
|
||||
notifications:
|
||||
administration_emails: Admin e-mail notifications
|
||||
email_events: Events for e-mail notifications
|
||||
email_events_hint: 'Select events that you want to receive notifications for:'
|
||||
other_settings: Other notifications settings
|
||||
@ -1477,6 +1491,15 @@ en:
|
||||
other: Other
|
||||
posting_defaults: Posting defaults
|
||||
public_timelines: Public timelines
|
||||
privacy:
|
||||
hint_html: "<strong>Customize how you want your profile and your posts to be found.</strong> A variety of features in Mastodon can help you reach a wider audience when enabled. Take a moment to review these settings to make sure they fit your use case."
|
||||
privacy: Privacy
|
||||
privacy_hint_html: Control how much you want to disclose for the benefit of others. People discover interesting profiles and cool apps by browsing other people's follows and seeing which apps they post from, but you may prefer to keep it hidden.
|
||||
reach: Reach
|
||||
reach_hint_html: Control whether you want to be discovered and followed by new people. Do you want your posts to appear on the Explore screen? Do you want other people to see you in their follow recommendations? Do you want to accept all new followers automatically, or have granular control over each one?
|
||||
search: Search
|
||||
search_hint_html: Control how you want to be found. Do you want people to find you by what you've publicly posted about? Do you want people outside Mastodon to find your profile when searching the web? Please mind that total exclusion from all search engines cannot be guaranteed for public information.
|
||||
title: Privacy and reach
|
||||
privacy_policy:
|
||||
title: Privacy Policy
|
||||
reactions:
|
||||
|
@ -1135,7 +1135,6 @@ eo:
|
||||
edit_profile:
|
||||
basic_information: Baza informo
|
||||
other: Alia
|
||||
safety_and_privacy: Sekureco kaj privateco
|
||||
errors:
|
||||
'400': La peto kiun vi sendis estas nevalida au malformas.
|
||||
'403': Vi ne havas la rajton por vidi ĉi tiun paĝon.
|
||||
|
@ -1140,7 +1140,6 @@ es-AR:
|
||||
basic_information: Información básica
|
||||
hint_html: "<strong>Personalizá lo que la gente ve en tu perfil público y junto a tus publicaciones.</strong> Es más probable que otras personas te sigan e interactúen con vos cuando tengas un perfil completo y una foto de perfil."
|
||||
other: Otros
|
||||
safety_and_privacy: Seguridad y privacidad
|
||||
errors:
|
||||
'400': La solicitud que enviaste no era válida o estaba corrompida.
|
||||
'403': No tenés permiso para ver esta página.
|
||||
|
@ -1140,7 +1140,6 @@ es-MX:
|
||||
basic_information: Información básica
|
||||
hint_html: "<strong>Personaliza lo que la gente ve en tu perfil público junto a tus publicaciones.</strong> Es más probable que otras personas te sigan e interactúen contigo cuando completes tu perfil y agregues una foto."
|
||||
other: Otro
|
||||
safety_and_privacy: Seguridad y privacidad
|
||||
errors:
|
||||
'400': La solicitud que has enviado no es valida o estaba malformada.
|
||||
'403': No tienes permiso para acceder a esta página.
|
||||
|
@ -1140,7 +1140,6 @@ es:
|
||||
basic_information: Información básica
|
||||
hint_html: "<strong>Personaliza lo que la gente ve en tu perfil público junto a tus publicaciones.</strong> Es más probable que otras personas te sigan e interactúen contigo cuando completas tu perfil y foto."
|
||||
other: Otros
|
||||
safety_and_privacy: Seguridad y privacidad
|
||||
errors:
|
||||
'400': La solicitud que has enviado no es valida o estaba malformada.
|
||||
'403': No tienes permiso para acceder a esta página.
|
||||
|
@ -1140,7 +1140,6 @@ et:
|
||||
basic_information: Põhiinfo
|
||||
hint_html: "<strong>Kohanda, mida inimesed näevad su avalikul profiilil ja postituste kõrval.</strong> Inimesed alustavad tõenäolisemalt sinu jälgimist ja interakteeruvad sinuga, kui sul on täidetud profiil ja profiilipilt."
|
||||
other: Muu
|
||||
safety_and_privacy: Turvalisus ja privaatsus
|
||||
errors:
|
||||
'400': Esitatud päring oli vigane või valesti vormindatud.
|
||||
'403': Sul puudub õigus seda lehte vaadata.
|
||||
|
@ -1140,7 +1140,6 @@ fi:
|
||||
basic_information: Perustiedot
|
||||
hint_html: "<strong>Mukauta mitä ihmiset näkevät julkisessa profiilissasi ja sinun julkaisujen vieressä.</strong> Ihmiset todennäköisesti seuraavat ja kirjoittavat sinulle, kun sinulla on täytetty profiili ja profiilikuva."
|
||||
other: Muu
|
||||
safety_and_privacy: Turvallisuus ja yksityisyys
|
||||
errors:
|
||||
'400': Lähettämäsi pyyntö oli virheellinen tai muotoiltu virheellisesti.
|
||||
'403': Sinulla ei ole lupaa nähdä tätä sivua.
|
||||
|
@ -1140,7 +1140,6 @@ fo:
|
||||
basic_information: Grundleggjandi upplýsingar
|
||||
hint_html: "<strong>Tillaga tað, sum fólk síggja á tínum almenna vanga og við síðna av tínum postum.</strong> Sannlíkindini fyri, at onnur fylgja tær og virka saman við tær eru størri, tá tú hevur fylt út vangan og eina vangamynd."
|
||||
other: Onnur
|
||||
safety_and_privacy: Trygd og privatlív
|
||||
errors:
|
||||
'400': Umbønin, sum tú sendi inn, var ógildug ella hevði skeivt skap.
|
||||
'403': Tú hevur ikki loyvi at síggja hesa síðuna.
|
||||
|
@ -1140,7 +1140,6 @@ fr-QC:
|
||||
basic_information: Informations de base
|
||||
hint_html: "<strong>Personnalisez ce que les gens voient sur votre profil public et à côté de vos messages.</strong> Les autres personnes seront plus susceptibles de vous suivre et d’interagir avec vous lorsque vous avez un profil complet et une photo."
|
||||
other: Autre
|
||||
safety_and_privacy: Sécurité et confidentialité
|
||||
errors:
|
||||
'400': La demande que vous avez soumise est invalide ou mal formée.
|
||||
'403': Vous n’avez pas accès à cette page.
|
||||
|
@ -1140,7 +1140,6 @@ fr:
|
||||
basic_information: Informations de base
|
||||
hint_html: "<strong>Personnalisez ce que les gens voient sur votre profil public et à côté de vos messages.</strong> Les autres personnes seront plus susceptibles de vous suivre et d’interagir avec vous lorsque vous avez un profil complet et une photo."
|
||||
other: Autre
|
||||
safety_and_privacy: Sécurité et confidentialité
|
||||
errors:
|
||||
'400': La demande que vous avez soumise est invalide ou mal formée.
|
||||
'403': Vous n’avez pas accès à cette page.
|
||||
|
@ -1140,7 +1140,6 @@ fy:
|
||||
basic_information: Algemiene ynformaasje
|
||||
hint_html: "<strong>Pas oan wat minsken op jo iepenbiere profyl en njonken jo berjochten sjogge.</strong> Oare minsken sille jo earder folgje en mei jo kommunisearje wannear’t jo profyl ynfolle is en jo in profylfoto hawwe."
|
||||
other: Oars
|
||||
safety_and_privacy: Feilichheid en privacy
|
||||
errors:
|
||||
'400': De oanfraach dy’t jo yntsjinne hawwe wie ûnjildich of fout.
|
||||
'403': Jo hawwe gjin tastimming om dizze side te besjen.
|
||||
|
@ -1176,7 +1176,6 @@ gd:
|
||||
basic_information: Fiosrachadh bunasach
|
||||
hint_html: "<strong>Gnàthaich na chithear air a’ phròifil phoblach agad is ri taobh nam postaichean agad.</strong> Bidh càch nas buailtiche do leantainn agus conaltradh leat nuair a bhios tu air a’ phròifil agad a lìonadh agus dealbh rithe."
|
||||
other: Eile
|
||||
safety_and_privacy: Sàbhailteachd is prìobhaideachd
|
||||
errors:
|
||||
'400': Cha robh an t-iarrtas a chuir thu a-null dligheach no bha droch-chruth air.
|
||||
'403': Chan eil cead agad gus an duilleag seo a shealltainn.
|
||||
|
@ -1140,7 +1140,6 @@ gl:
|
||||
basic_information: Información básica
|
||||
hint_html: "<strong>Personaliza o que van ver no teu perfil público e ao lado das túas publicacións.</strong> As outras persoas estarán máis animadas a seguirte e interactuar contigo se engades algún dato sobre ti así como unha imaxe de perfil."
|
||||
other: Outros
|
||||
safety_and_privacy: Seguridade e privacidade
|
||||
errors:
|
||||
'400': A solicitude que enviou non é válida ou ten formato incorrecto.
|
||||
'403': Non ten permiso para ver esta páxina.
|
||||
|
@ -1176,7 +1176,6 @@ he:
|
||||
basic_information: מידע בסיסי
|
||||
hint_html: "<strong>התאמה אישית של מה שיראו אחרים בפרופיל הציבורי שלך וליד הודעותיך.</strong> אחרים עשויים יותר להחזיר עוקב וליצור אתך שיחה אם הפרופיל והתמונה יהיו מלאים."
|
||||
other: אחר
|
||||
safety_and_privacy: בטחון ופרטיות
|
||||
errors:
|
||||
'400': הבקשה שהגשת לא תקינה.
|
||||
'403': חסרות לך הרשאות לצפיה בעמוד זה.
|
||||
|
@ -1140,7 +1140,6 @@ hu:
|
||||
basic_information: Általános információk
|
||||
hint_html: "<strong>Tedd egyedivé, mi látnak mások a profilodon és a bejegyzéseid mellett.</strong> Mások nagyobb eséllyel követnek vissza és lépnek veled kapcsolatba, ha van kitöltött profilod és profilképed."
|
||||
other: Egyéb
|
||||
safety_and_privacy: Biztonság és személyes adatok
|
||||
errors:
|
||||
'400': A küldött kérés érvénytelen vagy hibás volt.
|
||||
'403': Nincs jogosultságod az oldal megtekintéséhez.
|
||||
|
@ -1144,7 +1144,6 @@ is:
|
||||
basic_information: Grunnupplýsingar
|
||||
hint_html: "<strong>Sérsníddu hvað fólk sér á opinbera notandasniðinu þínu og næst færslunum þínum.</strong> Annað fólk er líklegra til að fylgjast með þér og eiga í samskiptum við þig ef þú fyllir út notandasniðið og setur auðkennismynd."
|
||||
other: Annað
|
||||
safety_and_privacy: Öryggi og friðhelgi
|
||||
errors:
|
||||
'400': Beiðnin sem þú sendir er ógild eða rangt uppsett.
|
||||
'403': Þú hefur ekki heimildir til að skoða þessari síðu.
|
||||
|
@ -1142,7 +1142,6 @@ it:
|
||||
basic_information: Informazioni di base
|
||||
hint_html: "<strong>Personalizza ciò che le persone vedono sul tuo profilo pubblico e accanto ai tuoi post.</strong> È più probabile che altre persone ti seguano e interagiscano con te quando hai un profilo compilato e un'immagine del profilo."
|
||||
other: Altro
|
||||
safety_and_privacy: Sicurezza e privacy
|
||||
errors:
|
||||
'400': La richiesta che hai inviato non è valida o non è corretta.
|
||||
'403': Non sei autorizzato a visualizzare questa pagina.
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user