Merge remote-tracking branch 'upstream/main'
All checks were successful
continuous-integration/drone Build is passing
All checks were successful
continuous-integration/drone Build is passing
This commit is contained in:
commit
06b7ebe45c
960 changed files with 17485 additions and 9440 deletions
|
@ -1,10 +1,6 @@
|
||||||
[production]
|
|
||||||
defaults
|
defaults
|
||||||
> 0.2%
|
> 0.2%
|
||||||
firefox >= 78
|
firefox >= 78
|
||||||
ios >= 15.6
|
ios >= 15.6
|
||||||
not dead
|
not dead
|
||||||
not OperaMini all
|
not OperaMini all
|
||||||
|
|
||||||
[development]
|
|
||||||
supports es6-module
|
|
||||||
|
|
|
@ -109,7 +109,7 @@ module.exports = defineConfig({
|
||||||
'react/jsx-equals-spacing': 'error',
|
'react/jsx-equals-spacing': 'error',
|
||||||
'react/jsx-no-bind': 'error',
|
'react/jsx-no-bind': 'error',
|
||||||
'react/jsx-no-useless-fragment': 'error',
|
'react/jsx-no-useless-fragment': 'error',
|
||||||
'react/jsx-no-target-blank': 'off',
|
'react/jsx-no-target-blank': ['error', { allowReferrer: true }],
|
||||||
'react/jsx-tag-spacing': 'error',
|
'react/jsx-tag-spacing': 'error',
|
||||||
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
'react/jsx-uses-react': 'off', // not needed with new JSX transform
|
||||||
'react/jsx-wrap-multilines': 'error',
|
'react/jsx-wrap-multilines': 'error',
|
||||||
|
|
|
@ -60,7 +60,7 @@ body:
|
||||||
Any additional technical details you may have, like logs or error traces
|
Any additional technical details you may have, like logs or error traces
|
||||||
value: |
|
value: |
|
||||||
If this is happening on your own Mastodon server, please fill out those:
|
If this is happening on your own Mastodon server, please fill out those:
|
||||||
- Ruby version: (from `ruby --version`, eg. v3.3.5)
|
- Ruby version: (from `ruby --version`, eg. v3.4.1)
|
||||||
- Node.js version: (from `node --version`, eg. v20.18.0)
|
- Node.js version: (from `node --version`, eg. v20.18.0)
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
2
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
2
.github/ISSUE_TEMPLATE/3.troubleshooting.yml
vendored
|
@ -61,7 +61,7 @@ body:
|
||||||
value: |
|
value: |
|
||||||
Please at least include those informations:
|
Please at least include those informations:
|
||||||
- Operating system: (eg. Ubuntu 22.04)
|
- Operating system: (eg. Ubuntu 22.04)
|
||||||
- Ruby version: (from `ruby --version`, eg. v3.3.5)
|
- Ruby version: (from `ruby --version`, eg. v3.4.1)
|
||||||
- Node.js version: (from `node --version`, eg. v20.18.0)
|
- Node.js version: (from `node --version`, eg. v20.18.0)
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
2
.github/workflows/check-i18n.yml
vendored
2
.github/workflows/check-i18n.yml
vendored
|
@ -18,7 +18,7 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-i18n:
|
check-i18n:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
|
@ -50,7 +50,7 @@ jobs:
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7.0.5
|
uses: peter-evans/create-pull-request@v7.0.6
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)'
|
||||||
|
|
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
|
@ -52,7 +52,7 @@ jobs:
|
||||||
|
|
||||||
# Create or update the pull request
|
# Create or update the pull request
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7.0.5
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
commit-message: 'New Crowdin translations'
|
commit-message: 'New Crowdin translations'
|
||||||
title: 'New Crowdin Translations (automated)'
|
title: 'New Crowdin Translations (automated)'
|
||||||
|
|
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
|
@ -40,4 +40,4 @@ jobs:
|
||||||
uses: ./.github/actions/setup-javascript
|
uses: ./.github/actions/setup-javascript
|
||||||
|
|
||||||
- name: Stylelint
|
- name: Stylelint
|
||||||
run: yarn lint:css -f github
|
run: yarn lint:css --custom-formatter @csstools/stylelint-formatter-github
|
||||||
|
|
2
.github/workflows/lint-ruby.yml
vendored
2
.github/workflows/lint-ruby.yml
vendored
|
@ -9,6 +9,7 @@ on:
|
||||||
- 'Gemfile*'
|
- 'Gemfile*'
|
||||||
- '.rubocop*.yml'
|
- '.rubocop*.yml'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
|
- 'bin/rubocop'
|
||||||
- 'config/brakeman.ignore'
|
- 'config/brakeman.ignore'
|
||||||
- '**/*.rb'
|
- '**/*.rb'
|
||||||
- '**/*.rake'
|
- '**/*.rake'
|
||||||
|
@ -19,6 +20,7 @@ on:
|
||||||
- 'Gemfile*'
|
- 'Gemfile*'
|
||||||
- '.rubocop*.yml'
|
- '.rubocop*.yml'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
|
- 'bin/rubocop'
|
||||||
- 'config/brakeman.ignore'
|
- 'config/brakeman.ignore'
|
||||||
- '**/*.rb'
|
- '**/*.rb'
|
||||||
- '**/*.rake'
|
- '**/*.rake'
|
||||||
|
|
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
|
@ -12,6 +12,7 @@ on:
|
||||||
- '**/*.rb'
|
- '**/*.rb'
|
||||||
- '.github/workflows/test-migrations.yml'
|
- '.github/workflows/test-migrations.yml'
|
||||||
- 'lib/tasks/tests.rake'
|
- 'lib/tasks/tests.rake'
|
||||||
|
- 'lib/tasks/db.rake'
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
@ -90,6 +91,11 @@ jobs:
|
||||||
bin/rails db:drop
|
bin/rails db:drop
|
||||||
bin/rails db:create
|
bin/rails db:create
|
||||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
|
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database
|
||||||
|
|
||||||
|
# Migrate up to v4.2.0 breakpoint
|
||||||
|
bin/rails db:migrate VERSION=20230907150100
|
||||||
|
|
||||||
|
# Migrate the rest
|
||||||
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
|
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate
|
||||||
bin/rails db:migrate
|
bin/rails db:migrate
|
||||||
bin/rails tests:migrations:check_database
|
bin/rails tests:migrations:check_database
|
||||||
|
|
10
.github/workflows/test-ruby.yml
vendored
10
.github/workflows/test-ruby.yml
vendored
|
@ -125,6 +125,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
ruby-version:
|
ruby-version:
|
||||||
- '3.2'
|
- '3.2'
|
||||||
|
- '3.3'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -166,7 +167,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.ruby-version == '.ruby-version'
|
if: matrix.ruby-version == '.ruby-version'
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
files: coverage/lcov/*.lcov
|
files: coverage/lcov/*.lcov
|
||||||
env:
|
env:
|
||||||
|
@ -174,7 +175,7 @@ jobs:
|
||||||
|
|
||||||
test-libvips:
|
test-libvips:
|
||||||
name: Libvips tests
|
name: Libvips tests
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
needs:
|
needs:
|
||||||
- build
|
- build
|
||||||
|
@ -226,6 +227,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
ruby-version:
|
ruby-version:
|
||||||
- '3.2'
|
- '3.2'
|
||||||
|
- '3.3'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -252,7 +254,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.ruby-version == '.ruby-version'
|
if: matrix.ruby-version == '.ruby-version'
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
files: coverage/lcov/mastodon.lcov
|
files: coverage/lcov/mastodon.lcov
|
||||||
env:
|
env:
|
||||||
|
@ -304,6 +306,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
ruby-version:
|
ruby-version:
|
||||||
- '3.2'
|
- '3.2'
|
||||||
|
- '3.3'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
@ -420,6 +423,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
ruby-version:
|
ruby-version:
|
||||||
- '3.2'
|
- '3.2'
|
||||||
|
- '3.3'
|
||||||
- '.ruby-version'
|
- '.ruby-version'
|
||||||
search-image:
|
search-image:
|
||||||
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13
|
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13
|
||||||
|
|
2
.nvmrc
2
.nvmrc
|
@ -1 +1 @@
|
||||||
22.11
|
22.13
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
---
|
---
|
||||||
|
Style/ArrayIntersect:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Style/ClassAndModuleChildren:
|
Style/ClassAndModuleChildren:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
@ -19,6 +22,13 @@ Style/HashSyntax:
|
||||||
EnforcedShorthandSyntax: either
|
EnforcedShorthandSyntax: either
|
||||||
EnforcedStyle: ruby19_no_mixed_keys
|
EnforcedStyle: ruby19_no_mixed_keys
|
||||||
|
|
||||||
|
Style/IfUnlessModifier:
|
||||||
|
Exclude:
|
||||||
|
- '**/*.haml'
|
||||||
|
|
||||||
|
Style/KeywordArgumentsMerging:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Style/NumericLiterals:
|
Style/NumericLiterals:
|
||||||
AllowedPatterns:
|
AllowedPatterns:
|
||||||
- \d{4}_\d{2}_\d{2}_\d{6}
|
- \d{4}_\d{2}_\d{2}_\d{6}
|
||||||
|
@ -37,6 +47,9 @@ Style/RedundantFetchBlock:
|
||||||
Style/RescueStandardError:
|
Style/RescueStandardError:
|
||||||
EnforcedStyle: implicit
|
EnforcedStyle: implicit
|
||||||
|
|
||||||
|
Style/SafeNavigationChainLength:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Style/SymbolArray:
|
Style/SymbolArray:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.66.1.
|
# using RuboCop version 1.69.2.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
Lint/NonLocalExitFromIterator:
|
Lint/NonLocalExitFromIterator:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/helpers/jsonld_helper.rb'
|
- 'app/helpers/json_ld_helper.rb'
|
||||||
|
|
||||||
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
|
||||||
Metrics/AbcSize:
|
Metrics/AbcSize:
|
||||||
|
@ -35,7 +35,6 @@ Rails/OutputSafety:
|
||||||
# Configuration parameters: AllowedVars.
|
# Configuration parameters: AllowedVars.
|
||||||
Style/FetchEnvVar:
|
Style/FetchEnvVar:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/lib/translation_service.rb'
|
|
||||||
- 'config/environments/production.rb'
|
- 'config/environments/production.rb'
|
||||||
- 'config/initializers/2_limited_federation_mode.rb'
|
- 'config/initializers/2_limited_federation_mode.rb'
|
||||||
- 'config/initializers/3_omniauth.rb'
|
- 'config/initializers/3_omniauth.rb'
|
||||||
|
@ -83,7 +82,7 @@ Style/MutableConstant:
|
||||||
# AllowedMethods: respond_to_missing?
|
# AllowedMethods: respond_to_missing?
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/helpers/jsonld_helper.rb'
|
- 'app/helpers/json_ld_helper.rb'
|
||||||
- 'app/lib/admin/system_check/message.rb'
|
- 'app/lib/admin/system_check/message.rb'
|
||||||
- 'app/lib/request.rb'
|
- 'app/lib/request.rb'
|
||||||
- 'app/lib/webfinger.rb'
|
- 'app/lib/webfinger.rb'
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.3.6
|
3.4.1
|
||||||
|
|
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -2,6 +2,48 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.3.2] - 2024-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire)
|
||||||
|
- Add error message when user tries to follow their own account (#31910 by @lenikadali)
|
||||||
|
- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change design of Content Warnings and filters (#32543 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire)
|
||||||
|
- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer)
|
||||||
|
- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire)
|
||||||
|
- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire)
|
||||||
|
- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire)
|
||||||
|
- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron)
|
||||||
|
- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire)
|
||||||
|
- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire)
|
||||||
|
- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire)
|
||||||
|
- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire)
|
||||||
|
- Fix titles being escaped twice (#32889 by @ClearlyClaire)
|
||||||
|
- Fix list creation limit check (#32869 by @ClearlyClaire)
|
||||||
|
- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski)
|
||||||
|
- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron)
|
||||||
|
- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire)
|
||||||
|
- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire)
|
||||||
|
- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire)
|
||||||
|
- Fix embed modal layout on mobile (#32641 by @DismalShadowX)
|
||||||
|
- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro)
|
||||||
|
- Fix blocks not being applied on link timeline (#32625 by @tribela)
|
||||||
|
- Fix follow counters being incorrectly changed (#32622 by @oneiros)
|
||||||
|
- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond)
|
||||||
|
- Fix tl language native name (#32606 by @seav)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
## [4.3.1] - 2024-10-21
|
## [4.3.1] - 2024-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -93,7 +135,7 @@ The following changelog entries focus on changes visible to users, administrator
|
||||||
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
|
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
|
||||||
Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\
|
Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\
|
||||||
Note that this does not notify remote users.\
|
Note that this does not notify remote users.\
|
||||||
This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`relationship_severance_event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event).
|
This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event).
|
||||||
- **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\
|
- **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\
|
||||||
Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\
|
Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\
|
||||||
This can be disabled in the “Animations and accessibility” section of the preferences.
|
This can be disabled in the “Animations and accessibility” section of the preferences.
|
||||||
|
|
|
@ -12,7 +12,7 @@ You can contribute in the following ways:
|
||||||
If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
|
If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
|
||||||
|
|
||||||
Please review the org-level [contribution guidelines] for high-level acceptance
|
Please review the org-level [contribution guidelines] for high-level acceptance
|
||||||
criteria guidance.
|
criteria guidance and the [DEVELOPMENT] guide for environment-specific details.
|
||||||
|
|
||||||
[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md
|
[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md
|
||||||
|
|
||||||
|
@ -53,3 +53,5 @@ It is not always possible to phrase every change in such a manner, but it is des
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation).
|
The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/documentation](https://github.com/mastodon/documentation).
|
||||||
|
|
||||||
|
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
||||||
|
|
|
@ -10,9 +10,9 @@
|
||||||
ARG TARGETPLATFORM=${TARGETPLATFORM}
|
ARG TARGETPLATFORM=${TARGETPLATFORM}
|
||||||
ARG BUILDPLATFORM=${BUILDPLATFORM}
|
ARG BUILDPLATFORM=${BUILDPLATFORM}
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.3.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.3.6"
|
ARG RUBY_VERSION="3.4.1"
|
||||||
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
@ -20,7 +20,7 @@ ARG NODE_MAJOR_VERSION="22"
|
||||||
ARG DEBIAN_VERSION="bookworm"
|
ARG DEBIAN_VERSION="bookworm"
|
||||||
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
|
||||||
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
|
||||||
# Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm)
|
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
|
||||||
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
|
||||||
|
|
||||||
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
|
||||||
|
|
18
Gemfile
18
Gemfile
|
@ -1,12 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
ruby '>= 3.2.0'
|
ruby '>= 3.2.0', '< 3.5.0'
|
||||||
|
|
||||||
gem 'propshaft'
|
gem 'propshaft'
|
||||||
gem 'puma', '~> 6.3'
|
gem 'puma', '~> 6.3'
|
||||||
gem 'rack', '~> 2.2.7'
|
gem 'rack', '~> 2.2.7'
|
||||||
gem 'rails', '~> 7.2.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
|
|
||||||
gem 'dotenv'
|
gem 'dotenv'
|
||||||
|
@ -73,13 +73,13 @@ gem 'public_suffix', '~> 6.0'
|
||||||
gem 'pundit', '~> 2.3'
|
gem 'pundit', '~> 2.3'
|
||||||
gem 'rack-attack', '~> 6.6'
|
gem 'rack-attack', '~> 6.6'
|
||||||
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
|
||||||
gem 'rails-i18n', '~> 7.0'
|
gem 'rails-i18n', '~> 8.0'
|
||||||
gem 'redcarpet', '~> 3.6'
|
gem 'redcarpet', '~> 3.6'
|
||||||
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
|
||||||
gem 'redis-namespace', '~> 1.10'
|
gem 'redis-namespace', '~> 1.10'
|
||||||
gem 'rqrcode', '~> 2.2'
|
gem 'rqrcode', '~> 2.2'
|
||||||
gem 'ruby-progressbar', '~> 1.13'
|
gem 'ruby-progressbar', '~> 1.13'
|
||||||
gem 'sanitize', '~> 6.0'
|
gem 'sanitize', '~> 7.0'
|
||||||
gem 'scenic', '~> 1.7'
|
gem 'scenic', '~> 1.7'
|
||||||
gem 'sidekiq', '~> 6.5'
|
gem 'sidekiq', '~> 6.5'
|
||||||
gem 'sidekiq-bulk', '~> 0.2.0'
|
gem 'sidekiq-bulk', '~> 0.2.0'
|
||||||
|
@ -105,16 +105,16 @@ gem 'opentelemetry-api', '~> 1.4.0'
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
|
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
|
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
|
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.21.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.22.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.24.1', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.23.2', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.22.3', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.34.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
|
@ -183,7 +183,7 @@ group :development do
|
||||||
gem 'letter_opener_web', '~> 3.0'
|
gem 'letter_opener_web', '~> 3.0'
|
||||||
|
|
||||||
# Security analysis CLI tools
|
# Security analysis CLI tools
|
||||||
gem 'brakeman', '~> 6.0', require: false
|
gem 'brakeman', '~> 7.0', require: false
|
||||||
gem 'bundler-audit', '~> 0.9', require: false
|
gem 'bundler-audit', '~> 0.9', require: false
|
||||||
|
|
||||||
# Linter CLI for HAML files
|
# Linter CLI for HAML files
|
||||||
|
@ -222,7 +222,7 @@ gem 'concurrent-ruby', require: false
|
||||||
gem 'connection_pool', require: false
|
gem 'connection_pool', require: false
|
||||||
gem 'xorcist', '~> 1.1'
|
gem 'xorcist', '~> 1.1'
|
||||||
|
|
||||||
gem 'net-http', '~> 0.5.0'
|
gem 'net-http', '~> 0.6.0'
|
||||||
gem 'rubyzip', '~> 2.3'
|
gem 'rubyzip', '~> 2.3'
|
||||||
|
|
||||||
gem 'hcaptcha', '~> 7.1'
|
gem 'hcaptcha', '~> 7.1'
|
||||||
|
|
307
Gemfile.lock
307
Gemfile.lock
|
@ -10,46 +10,45 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.2.2)
|
actioncable (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (7.2.2)
|
actionmailbox (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
activejob (= 7.2.2)
|
activejob (= 8.0.1)
|
||||||
activerecord (= 7.2.2)
|
activerecord (= 8.0.1)
|
||||||
activestorage (= 7.2.2)
|
activestorage (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (7.2.2)
|
actionmailer (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
actionview (= 7.2.2)
|
actionview (= 8.0.1)
|
||||||
activejob (= 7.2.2)
|
activejob (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (7.2.2)
|
actionpack (8.0.1)
|
||||||
actionview (= 7.2.2)
|
actionview (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
racc
|
rack (>= 2.2.4)
|
||||||
rack (>= 2.2.4, < 3.2)
|
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (7.2.2)
|
actiontext (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
activerecord (= 7.2.2)
|
activerecord (= 8.0.1)
|
||||||
activestorage (= 7.2.2)
|
activestorage (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.2.2)
|
actionview (8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
|
@ -59,22 +58,22 @@ GEM
|
||||||
activemodel (>= 4.1)
|
activemodel (>= 4.1)
|
||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
activejob (7.2.2)
|
activejob (8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.2.2)
|
activemodel (8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
activerecord (7.2.2)
|
activerecord (8.0.1)
|
||||||
activemodel (= 7.2.2)
|
activemodel (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (7.2.2)
|
activestorage (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
activejob (= 7.2.2)
|
activejob (= 8.0.1)
|
||||||
activerecord (= 7.2.2)
|
activerecord (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (7.2.2)
|
activesupport (8.0.1)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
|
@ -86,6 +85,7 @@ GEM
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
|
@ -94,8 +94,8 @@ GEM
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.3.0)
|
aws-eventstream (1.3.0)
|
||||||
aws-partitions (1.1015.0)
|
aws-partitions (1.1032.0)
|
||||||
aws-sdk-core (3.214.0)
|
aws-sdk-core (3.214.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
|
@ -103,13 +103,13 @@ GEM
|
||||||
aws-sdk-kms (1.96.0)
|
aws-sdk-kms (1.96.0)
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.175.0)
|
aws-sdk-s3 (1.177.0)
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.10.1)
|
aws-sigv4 (1.10.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-blob (0.5.3)
|
azure-blob (0.5.4)
|
||||||
rexml
|
rexml
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
|
@ -119,16 +119,16 @@ GEM
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
rouge (>= 1.0.0)
|
rouge (>= 1.0.0)
|
||||||
bigdecimal (3.1.8)
|
bigdecimal (3.1.9)
|
||||||
bindata (2.5.0)
|
bindata (2.5.0)
|
||||||
binding_of_caller (1.0.1)
|
binding_of_caller (1.0.1)
|
||||||
debug_inspector (>= 1.2.0)
|
debug_inspector (>= 1.2.0)
|
||||||
blurhash (0.1.8)
|
blurhash (0.1.8)
|
||||||
bootsnap (1.18.4)
|
bootsnap (1.18.4)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (6.2.2)
|
brakeman (7.0.0)
|
||||||
racc
|
racc
|
||||||
browser (6.1.0)
|
browser (6.2.0)
|
||||||
brpoplpush-redis_script (0.1.3)
|
brpoplpush-redis_script (0.1.3)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||||
redis (>= 1.0, < 6)
|
redis (>= 1.0, < 6)
|
||||||
|
@ -160,7 +160,7 @@ GEM
|
||||||
cocoon (1.2.15)
|
cocoon (1.2.15)
|
||||||
color_diff (0.1)
|
color_diff (0.1)
|
||||||
concurrent-ruby (1.3.4)
|
concurrent-ruby (1.3.4)
|
||||||
connection_pool (2.4.1)
|
connection_pool (2.5.0)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
|
@ -168,15 +168,15 @@ GEM
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
css_parser (1.19.1)
|
css_parser (1.21.0)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.0)
|
csv (3.3.2)
|
||||||
database_cleaner-active_record (2.2.0)
|
database_cleaner-active_record (2.2.0)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.0)
|
date (3.4.1)
|
||||||
debug (1.9.2)
|
debug (1.10.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
debug_inspector (1.2.0)
|
debug_inspector (1.2.0)
|
||||||
|
@ -199,9 +199,9 @@ GEM
|
||||||
activerecord (>= 4.2, < 9.0)
|
activerecord (>= 4.2, < 9.0)
|
||||||
docile (1.4.1)
|
docile (1.4.1)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
doorkeeper (5.8.0)
|
doorkeeper (5.8.1)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.1.4)
|
dotenv (3.1.7)
|
||||||
drb (2.2.1)
|
drb (2.2.1)
|
||||||
elasticsearch (7.17.11)
|
elasticsearch (7.17.11)
|
||||||
elasticsearch-api (= 7.17.11)
|
elasticsearch-api (= 7.17.11)
|
||||||
|
@ -217,32 +217,32 @@ GEM
|
||||||
htmlentities (~> 4.3.3)
|
htmlentities (~> 4.3.3)
|
||||||
launchy (>= 2.1, < 4.0)
|
launchy (>= 2.1, < 4.0)
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
erubi (1.13.0)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (0.112.0)
|
excon (0.112.0)
|
||||||
fabrication (2.31.0)
|
fabrication (2.31.0)
|
||||||
faker (3.5.1)
|
faker (3.5.1)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.12.0)
|
faraday (2.12.2)
|
||||||
faraday-net_http (>= 2.0, < 3.4)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-httpclient (2.0.1)
|
faraday-httpclient (2.0.1)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
faraday-net_http (3.3.0)
|
faraday-net_http (3.4.0)
|
||||||
net-http
|
net-http (>= 0.5.0)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.3.1)
|
fastimage (2.4.0)
|
||||||
ffi (1.17.0)
|
ffi (1.17.1)
|
||||||
ffi-compiler (1.3.2)
|
ffi-compiler (1.3.2)
|
||||||
ffi (>= 1.15.5)
|
ffi (>= 1.15.5)
|
||||||
rake
|
rake
|
||||||
flatware (2.3.3)
|
flatware (2.3.4)
|
||||||
drb
|
drb
|
||||||
thor (< 2.0)
|
thor (< 2.0)
|
||||||
flatware-rspec (2.3.3)
|
flatware-rspec (2.3.4)
|
||||||
flatware (= 2.3.3)
|
flatware (= 2.3.4)
|
||||||
rspec (>= 3.6)
|
rspec (>= 3.6)
|
||||||
fog-core (2.5.0)
|
fog-core (2.5.0)
|
||||||
builder
|
builder
|
||||||
|
@ -279,7 +279,7 @@ GEM
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.1.1)
|
hashdiff (1.1.2)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
|
@ -294,7 +294,7 @@ GEM
|
||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 2.2)
|
http-form_data (~> 2.2)
|
||||||
llhttp-ffi (~> 0.5.0)
|
llhttp-ffi (~> 0.5.0)
|
||||||
http-cookie (1.0.5)
|
http-cookie (1.0.8)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
|
@ -318,8 +318,8 @@ GEM
|
||||||
inline_svg (1.10.0)
|
inline_svg (1.10.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.7.2)
|
io-console (0.8.0)
|
||||||
irb (1.14.1)
|
irb (1.14.3)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jd-paperclip-azure (3.0.0)
|
jd-paperclip-azure (3.0.0)
|
||||||
|
@ -327,7 +327,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.8.1)
|
json (2.9.1)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.15.3.1)
|
json-jwt (1.15.3.1)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
@ -384,13 +384,13 @@ GEM
|
||||||
llhttp-ffi (0.5.0)
|
llhttp-ffi (0.5.0)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
logger (1.6.1)
|
logger (1.6.4)
|
||||||
lograge (0.14.0)
|
lograge (0.14.0)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.23.1)
|
loofah (2.24.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.8.1)
|
mail (2.8.1)
|
||||||
|
@ -406,16 +406,16 @@ GEM
|
||||||
mime-types (3.6.0)
|
mime-types (3.6.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2024.1105)
|
mime-types-data (3.2024.1203)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.8)
|
mini_portile2 (2.8.8)
|
||||||
minitest (5.25.2)
|
minitest (5.25.4)
|
||||||
msgpack (1.7.5)
|
msgpack (1.7.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.5.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.1)
|
net-imap (0.5.4)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
|
@ -426,10 +426,10 @@ GEM
|
||||||
net-smtp (0.5.0)
|
net-smtp (0.5.0)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.16.8)
|
nokogiri (1.18.1)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.7)
|
oj (3.16.9)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.2)
|
omniauth (2.1.2)
|
||||||
|
@ -460,13 +460,13 @@ GEM
|
||||||
validate_email
|
validate_email
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 1.2)
|
webfinger (~> 1.2)
|
||||||
openssl (3.2.0)
|
openssl (3.2.1)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.4.0)
|
opentelemetry-api (1.4.0)
|
||||||
opentelemetry-common (0.21.0)
|
opentelemetry-common (0.21.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-exporter-otlp (0.29.0)
|
opentelemetry-exporter-otlp (0.29.1)
|
||||||
google-protobuf (>= 3.18)
|
google-protobuf (>= 3.18)
|
||||||
googleapis-common-protos-types (~> 1.3)
|
googleapis-common-protos-types (~> 1.3)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
|
@ -475,28 +475,29 @@ GEM
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql-obfuscation (0.2.1)
|
opentelemetry-helpers-sql-obfuscation (0.2.1)
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.2.0)
|
opentelemetry-instrumentation-action_mailer (0.3.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-action_pack (0.10.0)
|
opentelemetry-instrumentation-action_pack (0.10.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_view (0.7.3)
|
opentelemetry-instrumentation-action_view (0.8.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.6)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_job (0.7.8)
|
opentelemetry-instrumentation-active_job (0.7.8)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
opentelemetry-instrumentation-active_model_serializers (0.21.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_record (0.8.1)
|
opentelemetry-instrumentation-active_record (0.8.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_support (0.6.0)
|
opentelemetry-instrumentation-active_support (0.7.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-base (0.22.6)
|
opentelemetry-instrumentation-base (0.22.6)
|
||||||
|
@ -509,7 +510,7 @@ GEM
|
||||||
opentelemetry-instrumentation-excon (0.22.5)
|
opentelemetry-instrumentation-excon (0.22.5)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-faraday (0.24.7)
|
opentelemetry-instrumentation-faraday (0.25.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-http (0.23.5)
|
opentelemetry-instrumentation-http (0.23.5)
|
||||||
|
@ -521,21 +522,21 @@ GEM
|
||||||
opentelemetry-instrumentation-net_http (0.22.8)
|
opentelemetry-instrumentation-net_http (0.22.8)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-pg (0.29.1)
|
opentelemetry-instrumentation-pg (0.29.2)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rack (0.25.0)
|
opentelemetry-instrumentation-rack (0.25.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-rails (0.33.1)
|
opentelemetry-instrumentation-rails (0.34.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
|
opentelemetry-instrumentation-action_mailer (~> 0.3.0)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
opentelemetry-instrumentation-action_pack (~> 0.10.0)
|
||||||
opentelemetry-instrumentation-action_view (~> 0.7.0)
|
opentelemetry-instrumentation-action_view (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
opentelemetry-instrumentation-active_job (~> 0.7.0)
|
||||||
opentelemetry-instrumentation-active_record (~> 0.8.0)
|
opentelemetry-instrumentation-active_record (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.6.0)
|
opentelemetry-instrumentation-active_support (~> 0.7.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-redis (0.25.7)
|
opentelemetry-instrumentation-redis (0.25.7)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
|
@ -545,7 +546,7 @@ GEM
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-registry (0.3.1)
|
opentelemetry-registry (0.3.1)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-sdk (1.5.0)
|
opentelemetry-sdk (1.6.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-registry (~> 0.2)
|
opentelemetry-registry (~> 0.2)
|
||||||
|
@ -554,7 +555,8 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
ox (2.14.18)
|
ox (2.14.19)
|
||||||
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.26.3)
|
parallel (1.26.3)
|
||||||
parser (3.3.6.0)
|
parser (3.3.6.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
|
@ -578,7 +580,8 @@ GEM
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
psych (5.2.0)
|
psych (5.2.2)
|
||||||
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.1)
|
||||||
puma (6.5.0)
|
puma (6.5.0)
|
||||||
|
@ -605,25 +608,25 @@ GEM
|
||||||
rack
|
rack
|
||||||
rack-session (1.0.2)
|
rack-session (1.0.2)
|
||||||
rack (< 3)
|
rack (< 3)
|
||||||
rack-test (2.1.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (1.0.0)
|
rackup (1.0.1)
|
||||||
rack (< 3)
|
rack (< 3)
|
||||||
webrick
|
webrick
|
||||||
rails (7.2.2)
|
rails (8.0.1)
|
||||||
actioncable (= 7.2.2)
|
actioncable (= 8.0.1)
|
||||||
actionmailbox (= 7.2.2)
|
actionmailbox (= 8.0.1)
|
||||||
actionmailer (= 7.2.2)
|
actionmailer (= 8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
actiontext (= 7.2.2)
|
actiontext (= 8.0.1)
|
||||||
actionview (= 7.2.2)
|
actionview (= 8.0.1)
|
||||||
activejob (= 7.2.2)
|
activejob (= 8.0.1)
|
||||||
activemodel (= 7.2.2)
|
activemodel (= 8.0.1)
|
||||||
activerecord (= 7.2.2)
|
activerecord (= 8.0.1)
|
||||||
activestorage (= 7.2.2)
|
activestorage (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.2.2)
|
railties (= 8.0.1)
|
||||||
rails-controller-testing (1.0.5)
|
rails-controller-testing (1.0.5)
|
||||||
actionpack (>= 5.0.1.rc1)
|
actionpack (>= 5.0.1.rc1)
|
||||||
actionview (>= 5.0.1.rc1)
|
actionview (>= 5.0.1.rc1)
|
||||||
|
@ -632,15 +635,15 @@ GEM
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.6.0)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (~> 1.14)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
rails-i18n (7.0.10)
|
rails-i18n (8.0.1)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 6.0.0, < 8)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (7.2.2)
|
railties (8.0.1)
|
||||||
actionpack (= 7.2.2)
|
actionpack (= 8.0.1)
|
||||||
activesupport (= 7.2.2)
|
activesupport (= 8.0.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
|
@ -654,7 +657,7 @@ GEM
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.7.0)
|
rdoc (6.10.0)
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
redcarpet (3.6.0)
|
redcarpet (3.6.0)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
|
@ -662,15 +665,15 @@ GEM
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
regexp_parser (2.9.2)
|
regexp_parser (2.10.0)
|
||||||
reline (0.5.11)
|
reline (0.6.0)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.6.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
responders (3.1.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.3.9)
|
rexml (3.4.0)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.5.1)
|
rouge (4.5.1)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
|
@ -705,30 +708,30 @@ GEM
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 8)
|
sidekiq (>= 5, < 8)
|
||||||
rspec-support (3.13.1)
|
rspec-support (3.13.2)
|
||||||
rubocop (1.66.1)
|
rubocop (1.69.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (>= 3.17.0)
|
language_server-protocol (>= 3.17.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.4, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.32.2, < 2.0)
|
rubocop-ast (>= 1.36.2, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.32.3)
|
rubocop-ast (1.37.0)
|
||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.1.0)
|
||||||
rubocop-capybara (2.21.0)
|
rubocop-capybara (2.21.0)
|
||||||
rubocop (~> 1.41)
|
rubocop (~> 1.41)
|
||||||
rubocop-performance (1.22.1)
|
rubocop-performance (1.23.1)
|
||||||
rubocop (>= 1.48.1, < 2.0)
|
rubocop (>= 1.48.1, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rails (2.27.0)
|
rubocop-rails (2.28.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.52.0, < 2.0)
|
rubocop (>= 1.52.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.31.1, < 2.0)
|
||||||
rubocop-rspec (3.2.0)
|
rubocop-rspec (3.3.0)
|
||||||
rubocop (~> 1.61)
|
rubocop (~> 1.61)
|
||||||
rubocop-rspec_rails (2.30.0)
|
rubocop-rspec_rails (2.30.0)
|
||||||
rubocop (~> 1.61)
|
rubocop (~> 1.61)
|
||||||
|
@ -741,25 +744,25 @@ GEM
|
||||||
ruby-vips (2.2.2)
|
ruby-vips (2.2.2)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.4.1)
|
||||||
rufus-scheduler (3.9.1)
|
rufus-scheduler (3.9.2)
|
||||||
fugit (~> 1.1, >= 1.1.6)
|
fugit (~> 1.1, >= 1.11.1)
|
||||||
safety_net_attestation (0.4.0)
|
safety_net_attestation (0.4.0)
|
||||||
jwt (~> 2.0)
|
jwt (~> 2.0)
|
||||||
sanitize (6.1.3)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.16.8)
|
||||||
scenic (1.8.0)
|
scenic (1.8.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.3.2)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.27.0)
|
selenium-webdriver (4.27.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
semantic_range (3.0.0)
|
semantic_range (3.1.0)
|
||||||
shoulda-matchers (6.4.0)
|
shoulda-matchers (6.4.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (6.5.12)
|
sidekiq (6.5.12)
|
||||||
|
@ -806,10 +809,10 @@ GEM
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
terrapin (1.0.1)
|
terrapin (1.0.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.4.2)
|
test-prof (1.4.4)
|
||||||
thor (1.3.2)
|
thor (1.3.2)
|
||||||
tilt (2.4.0)
|
tilt (2.5.0)
|
||||||
timeout (0.4.2)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.12.1)
|
tpm-key_attestation (0.12.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
|
@ -835,8 +838,8 @@ GEM
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
unf_ext (0.0.9.1)
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
uri (0.13.1)
|
uri (1.0.2)
|
||||||
useragent (0.16.10)
|
useragent (0.16.11)
|
||||||
validate_email (0.1.6)
|
validate_email (0.1.6)
|
||||||
activemodel (>= 3.0)
|
activemodel (>= 3.0)
|
||||||
mail (>= 2.2.5)
|
mail (>= 2.2.5)
|
||||||
|
@ -865,7 +868,7 @@ GEM
|
||||||
rack-proxy (>= 0.6.1)
|
rack-proxy (>= 0.6.1)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
semantic_range (>= 2.3.0)
|
semantic_range (>= 2.3.0)
|
||||||
webrick (1.9.0)
|
webrick (1.9.1)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
|
@ -888,7 +891,7 @@ DEPENDENCIES
|
||||||
binding_of_caller (~> 1.0)
|
binding_of_caller (~> 1.0)
|
||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
bootsnap (~> 1.18.0)
|
bootsnap (~> 1.18.0)
|
||||||
brakeman (~> 6.0)
|
brakeman (~> 7.0)
|
||||||
browser
|
browser
|
||||||
bundler-audit (~> 0.9)
|
bundler-audit (~> 0.9)
|
||||||
capybara (~> 3.39)
|
capybara (~> 3.39)
|
||||||
|
@ -945,7 +948,7 @@ DEPENDENCIES
|
||||||
memory_profiler
|
memory_profiler
|
||||||
mime-types (~> 3.6.0)
|
mime-types (~> 3.6.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
net-http (~> 0.5.0)
|
net-http (~> 0.6.0)
|
||||||
net-ldap (~> 0.18)
|
net-ldap (~> 0.18)
|
||||||
nokogiri (~> 1.15)
|
nokogiri (~> 1.15)
|
||||||
oj (~> 3.14)
|
oj (~> 3.14)
|
||||||
|
@ -957,16 +960,16 @@ DEPENDENCIES
|
||||||
opentelemetry-api (~> 1.4.0)
|
opentelemetry-api (~> 1.4.0)
|
||||||
opentelemetry-exporter-otlp (~> 0.29.0)
|
opentelemetry-exporter-otlp (~> 0.29.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.7.1)
|
opentelemetry-instrumentation-active_job (~> 0.7.1)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.21.0)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
|
||||||
opentelemetry-instrumentation-excon (~> 0.22.0)
|
opentelemetry-instrumentation-excon (~> 0.22.0)
|
||||||
opentelemetry-instrumentation-faraday (~> 0.24.1)
|
opentelemetry-instrumentation-faraday (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.23.2)
|
opentelemetry-instrumentation-http (~> 0.23.2)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.22.3)
|
opentelemetry-instrumentation-http_client (~> 0.22.3)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
opentelemetry-instrumentation-net_http (~> 0.22.4)
|
||||||
opentelemetry-instrumentation-pg (~> 0.29.0)
|
opentelemetry-instrumentation-pg (~> 0.29.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.25.0)
|
opentelemetry-instrumentation-rack (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-rails (~> 0.33.0)
|
opentelemetry-instrumentation-rails (~> 0.34.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.25.3)
|
opentelemetry-instrumentation-redis (~> 0.25.3)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
|
@ -983,9 +986,9 @@ DEPENDENCIES
|
||||||
rack-attack (~> 6.6)
|
rack-attack (~> 6.6)
|
||||||
rack-cors (~> 2.0)
|
rack-cors (~> 2.0)
|
||||||
rack-test (~> 2.1)
|
rack-test (~> 2.1)
|
||||||
rails (~> 7.2.0)
|
rails (~> 8.0)
|
||||||
rails-controller-testing (~> 1.0)
|
rails-controller-testing (~> 1.0)
|
||||||
rails-i18n (~> 7.0)
|
rails-i18n (~> 8.0)
|
||||||
rdf-normalize (~> 0.5)
|
rdf-normalize (~> 0.5)
|
||||||
redcarpet (~> 3.6)
|
redcarpet (~> 3.6)
|
||||||
redis (~> 4.5)
|
redis (~> 4.5)
|
||||||
|
@ -1004,7 +1007,7 @@ DEPENDENCIES
|
||||||
ruby-progressbar (~> 1.13)
|
ruby-progressbar (~> 1.13)
|
||||||
ruby-vips (~> 2.2)
|
ruby-vips (~> 2.2)
|
||||||
rubyzip (~> 2.3)
|
rubyzip (~> 2.3)
|
||||||
sanitize (~> 6.0)
|
sanitize (~> 7.0)
|
||||||
scenic (~> 1.7)
|
scenic (~> 1.7)
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
|
@ -1031,7 +1034,7 @@ DEPENDENCIES
|
||||||
xorcist (~> 1.1)
|
xorcist (~> 1.1)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.6p108
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.23
|
2.6.2
|
||||||
|
|
147
README.md
147
README.md
|
@ -1,24 +1,24 @@
|
||||||
<h1><picture>
|
> [!NOTE]
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./lib/assets/wordmark.dark.png?raw=true">
|
> Want to learn more about Mastodon?
|
||||||
<source media="(prefers-color-scheme: light)" srcset="./lib/assets/wordmark.light.png?raw=true">
|
> Click below to find out more in a video.
|
||||||
<img alt="Mastodon" src="./lib/assets/wordmark.light.png?raw=true" height="34">
|
|
||||||
</picture></h1>
|
|
||||||
|
|
||||||
[![GitHub release](https://img.shields.io/github/release/mastodon/mastodon.svg)][releases]
|
<p align="center">
|
||||||
[![Ruby Testing](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg)](https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml)
|
<a style="text-decoration:none" href="https://www.youtube.com/watch?v=IPSbNdBmWKE">
|
||||||
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
|
<img alt="Mastodon hero image" src="https://github.com/user-attachments/assets/ef53f5e9-c0d8-484d-9f53-00efdebb92c3" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[releases]: https://github.com/mastodon/mastodon/releases
|
<p align="center">
|
||||||
[crowdin]: https://crowdin.com/project/mastodon
|
<a style="text-decoration:none" href="https://github.com/mastodon/mastodon/releases">
|
||||||
|
<img src="https://img.shields.io/github/release/mastodon/mastodon.svg" alt="Release" /></a>
|
||||||
|
<a style="text-decoration:none" href="https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml">
|
||||||
|
<img src="https://github.com/mastodon/mastodon/actions/workflows/test-ruby.yml/badge.svg" alt="Ruby Testing" /></a>
|
||||||
|
<a style="text-decoration:none" href="https://crowdin.com/project/mastodon">
|
||||||
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||||
|
|
||||||
Click below to **learn more** in a video:
|
|
||||||
|
|
||||||
[![Screenshot](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/ezgif-2-60f1b00403.gif)][youtube_demo]
|
|
||||||
|
|
||||||
[youtube_demo]: https://www.youtube.com/watch?v=IPSbNdBmWKE
|
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
|
@ -37,25 +37,15 @@ Click below to **learn more** in a video:
|
||||||
|
|
||||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||||
|
|
||||||
### No vendor lock-in: Fully interoperable with any conforming platform
|
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
||||||
|
|
||||||
It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
||||||
|
|
||||||
### Real-time, chronological timeline updates
|
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
||||||
|
|
||||||
Updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
||||||
|
|
||||||
### Media attachments like images and short videos
|
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
||||||
|
|
||||||
Upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
|
||||||
|
|
||||||
### Safety and moderation tools
|
|
||||||
|
|
||||||
Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
|
||||||
|
|
||||||
### OAuth2 and a straightforward REST API
|
|
||||||
|
|
||||||
Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
@ -74,85 +64,40 @@ Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Stre
|
||||||
|
|
||||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Vagrant
|
|
||||||
|
|
||||||
A **Vagrant** configuration is included for development purposes. To use it, complete the following steps:
|
|
||||||
|
|
||||||
- Install Vagrant and Virtualbox
|
|
||||||
- Install the `vagrant-hostsupdater` plugin: `vagrant plugin install vagrant-hostsupdater`
|
|
||||||
- Run `vagrant up`
|
|
||||||
- Run `vagrant ssh -c "cd /vagrant && bin/dev"`
|
|
||||||
- Open `http://mastodon.local` in your browser
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
To set up **macOS** for native development, complete the following steps:
|
|
||||||
|
|
||||||
- Install [Homebrew] and run `brew install postgresql@14 redis imagemagick
|
|
||||||
libidn nvm` to install the required project dependencies
|
|
||||||
- Use a Ruby version manager to activate the ruby in `.ruby-version` and run
|
|
||||||
`nvm use` to activate the node version from `.nvmrc`
|
|
||||||
- Run the `bin/setup` script, which will install the required ruby gems and node
|
|
||||||
packages and prepare the database for local development
|
|
||||||
- Finally, run the `bin/dev` script which will launch services via `overmind`
|
|
||||||
(if installed) or `foreman`
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
For production hosting and deployment with **Docker**, use the `Dockerfile` and
|
|
||||||
`docker-compose.yml` in the project root directory.
|
|
||||||
|
|
||||||
For local development, install and launch [Docker], and run:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
docker compose -f .devcontainer/compose.yaml up -d
|
|
||||||
docker compose -f .devcontainer/compose.yaml exec app bin/setup
|
|
||||||
docker compose -f .devcontainer/compose.yaml exec app bin/dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dev Containers
|
|
||||||
|
|
||||||
Within IDEs that support the [Development Containers] specification, start the
|
|
||||||
"Mastodon on local machine" container from the editor. The necessary `docker
|
|
||||||
compose` commands to build and setup the container should run automatically. For
|
|
||||||
**Visual Studio Code** this requires installing the [Dev Container extension].
|
|
||||||
|
|
||||||
### GitHub Codespaces
|
|
||||||
|
|
||||||
[GitHub Codespaces] provides a web-based version of VS Code and a cloud hosted
|
|
||||||
development environment configured with the software needed for this project.
|
|
||||||
|
|
||||||
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)][codespace]
|
|
||||||
|
|
||||||
- Click the button to create a new codespace, and confirm the options
|
|
||||||
- Wait for the environment to build (takes a few minutes)
|
|
||||||
- When the editor is ready, run `bin/dev` in the terminal
|
|
||||||
- Wait for an _Open in Browser_ prompt. This will open Mastodon
|
|
||||||
- On the _Ports_ tab "stream" setting change _Port visibility_ → _Public_
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
||||||
|
|
||||||
You can open issues for bugs you've found or features you think are missing. You can also submit pull requests to this repository or submit translations using Crowdin. To get started, take a look at [CONTRIBUTING.md](CONTRIBUTING.md). If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).
|
You can open issues for bugs you've found or features you think are missing. You
|
||||||
|
can also submit pull requests to this repository or translations via Crowdin. To
|
||||||
|
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
||||||
|
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
||||||
|
|
||||||
**IRC channel**: #mastodon on irc.libera.chat
|
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2016-2024 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md))
|
Copyright (c) 2016-2024 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
```
|
||||||
|
Copyright (c) 2016-2024 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
Software Foundation, either version 3 of the License, or (at your option) any
|
||||||
|
later version.
|
||||||
|
|
||||||
[codespace]: https://codespaces.new/mastodon/mastodon?quickstart=1&devcontainer_path=.devcontainer%2Fcodespaces%2Fdevcontainer.json
|
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
[Dev Container extension]: https://containers.dev/supporting#dev-containers
|
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
[Development Containers]: https://containers.dev/supporting
|
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
[Docker]: https://docs.docker.com
|
details.
|
||||||
[GitHub Codespaces]: https://docs.github.com/en/codespaces
|
|
||||||
[Homebrew]: https://brew.sh
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
|
with this program. If not, see https://www.gnu.org/licenses/
|
||||||
|
```
|
||||||
|
|
||||||
|
[CONTRIBUTING]: CONTRIBUTING.md
|
||||||
|
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
||||||
|
[OpenCollective]: https://opencollective.com/mastodon
|
||||||
|
|
2
Vagrantfile
vendored
2
Vagrantfile
vendored
|
@ -174,7 +174,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||||
if config.vm.networks.any? { |type, options| type == :private_network }
|
if config.vm.networks.any? { |type, options| type == :private_network }
|
||||||
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
|
config.vm.synced_folder ".", "/vagrant", type: "nfs", mount_options: ['rw', 'actimeo=1']
|
||||||
else
|
else
|
||||||
config.vm.synced_folder ".", "/vagrant"
|
config.vm.synced_folder ".", "/vagrant", type: "rsync", create: true, rsync__args: ["--verbose", "--archive", "--delete", "-z"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080
|
# Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080
|
||||||
|
|
|
@ -8,6 +8,7 @@ module Admin
|
||||||
layout 'admin'
|
layout 'admin'
|
||||||
|
|
||||||
before_action :set_cache_headers
|
before_action :set_cache_headers
|
||||||
|
before_action :set_referrer_policy_header
|
||||||
|
|
||||||
after_action :verify_authorized
|
after_action :verify_authorized
|
||||||
|
|
||||||
|
@ -17,6 +18,10 @@ module Admin
|
||||||
response.cache_control.replace(private: true, no_store: true)
|
response.cache_control.replace(private: true, no_store: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_referrer_policy_header
|
||||||
|
response.headers['Referrer-Policy'] = 'same-origin'
|
||||||
|
end
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
|
@user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,7 +6,7 @@ module Admin
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :software_update, :index?
|
authorize :software_update, :index?
|
||||||
@software_updates = SoftwareUpdate.all.sort_by(&:gem_version)
|
@software_updates = SoftwareUpdate.by_version
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::DistributionsController < Admin::BaseController
|
||||||
|
before_action :set_terms_of_service
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize @terms_of_service, :distribute?
|
||||||
|
@terms_of_service.touch(:notification_sent_at)
|
||||||
|
Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id)
|
||||||
|
redirect_to admin_terms_of_service_index_path
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_terms_of_service
|
||||||
|
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||||
|
end
|
||||||
|
end
|
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal file
36
app/controllers/admin/terms_of_service/drafts_controller.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::DraftsController < Admin::BaseController
|
||||||
|
before_action :set_terms_of_service
|
||||||
|
|
||||||
|
def show
|
||||||
|
authorize :terms_of_service, :create?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @terms_of_service, :update?
|
||||||
|
|
||||||
|
@terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish'
|
||||||
|
|
||||||
|
if @terms_of_service.update(resource_params)
|
||||||
|
log_action(:publish, @terms_of_service) if @terms_of_service.published?
|
||||||
|
redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path
|
||||||
|
else
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_terms_of_service
|
||||||
|
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_terms_of_service
|
||||||
|
TermsOfService.live.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:terms_of_service).permit(:text, :changelog)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,37 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::GeneratesController < Admin::BaseController
|
||||||
|
before_action :set_instance_presenter
|
||||||
|
|
||||||
|
def show
|
||||||
|
authorize :terms_of_service, :create?
|
||||||
|
|
||||||
|
@generator = TermsOfService::Generator.new(
|
||||||
|
domain: @instance_presenter.domain,
|
||||||
|
admin_email: @instance_presenter.contact.email
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize :terms_of_service, :create?
|
||||||
|
|
||||||
|
@generator = TermsOfService::Generator.new(resource_params)
|
||||||
|
|
||||||
|
if @generator.valid?
|
||||||
|
TermsOfService.create!(text: @generator.render)
|
||||||
|
redirect_to admin_terms_of_service_draft_path
|
||||||
|
else
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_instance_presenter
|
||||||
|
@instance_presenter = InstancePresenter.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource_params
|
||||||
|
params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::HistoriesController < Admin::BaseController
|
||||||
|
def show
|
||||||
|
authorize :terms_of_service, :index?
|
||||||
|
@terms_of_service = TermsOfService.published.all
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::PreviewsController < Admin::BaseController
|
||||||
|
before_action :set_terms_of_service
|
||||||
|
|
||||||
|
def show
|
||||||
|
authorize @terms_of_service, :distribute?
|
||||||
|
@user_count = @terms_of_service.scope_for_notification.count
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_terms_of_service
|
||||||
|
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||||
|
end
|
||||||
|
end
|
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal file
17
app/controllers/admin/terms_of_service/tests_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfService::TestsController < Admin::BaseController
|
||||||
|
before_action :set_terms_of_service
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize @terms_of_service, :distribute?
|
||||||
|
UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later!
|
||||||
|
redirect_to admin_terms_of_service_preview_path(@terms_of_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_terms_of_service
|
||||||
|
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
|
||||||
|
end
|
||||||
|
end
|
8
app/controllers/admin/terms_of_service_controller.rb
Normal file
8
app/controllers/admin/terms_of_service_controller.rb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::TermsOfServiceController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
authorize :terms_of_service, :index?
|
||||||
|
@terms_of_service = TermsOfService.live.first
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,16 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController
|
||||||
|
before_action :set_terms_of_service
|
||||||
|
|
||||||
|
def show
|
||||||
|
cache_even_if_authenticated!
|
||||||
|
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_terms_of_service
|
||||||
|
@terms_of_service = TermsOfService.live.first!
|
||||||
|
end
|
||||||
|
end
|
|
@ -15,7 +15,7 @@ class Api::V1::Polls::VotesController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_poll
|
def set_poll
|
||||||
@poll = Poll.attached.find(params[:poll_id])
|
@poll = Poll.find(params[:poll_id])
|
||||||
authorize @poll.status, :show?
|
authorize @poll.status, :show?
|
||||||
rescue Mastodon::NotPermittedError
|
rescue Mastodon::NotPermittedError
|
||||||
not_found
|
not_found
|
||||||
|
|
|
@ -15,7 +15,7 @@ class Api::V1::PollsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_poll
|
def set_poll
|
||||||
@poll = Poll.attached.find(params[:id])
|
@poll = Poll.find(params[:id])
|
||||||
authorize @poll.status, :show?
|
authorize @poll.status, :show?
|
||||||
rescue Mastodon::NotPermittedError
|
rescue Mastodon::NotPermittedError
|
||||||
not_found
|
not_found
|
||||||
|
|
|
@ -27,7 +27,9 @@ class Api::V1::Trends::TagsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def tags_from_trends
|
def tags_from_trends
|
||||||
Trends.tags.query.allowed
|
scope = Trends.tags.query.allowed.in_locale(content_locale)
|
||||||
|
scope = scope.filtered_for(current_account) if user_signed_in?
|
||||||
|
scope
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_path
|
def next_path
|
||||||
|
|
|
@ -80,10 +80,31 @@ class Api::V2::NotificationsController < Api::BaseController
|
||||||
return [] if @notifications.empty?
|
return [] if @notifications.empty?
|
||||||
|
|
||||||
MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do
|
MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do
|
||||||
NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types])
|
pagination_range = (@notifications.last.id)..@notifications.first.id
|
||||||
|
|
||||||
|
# If the page is incomplete, we know we are on the last page
|
||||||
|
if incomplete_page?
|
||||||
|
if paginating_up?
|
||||||
|
pagination_range = @notifications.last.id...(params[:max_id]&.to_i)
|
||||||
|
else
|
||||||
|
range_start = params[:since_id]&.to_i
|
||||||
|
range_start += 1 unless range_start.nil?
|
||||||
|
pagination_range = range_start..(@notifications.first.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
NotificationGroup.from_notifications(@notifications, pagination_range: pagination_range, grouped_types: params[:grouped_types])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def incomplete_page?
|
||||||
|
@notifications.size < limit_param(DEFAULT_NOTIFICATIONS_LIMIT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginating_up?
|
||||||
|
params[:min_id].present?
|
||||||
|
end
|
||||||
|
|
||||||
def browserable_account_notifications
|
def browserable_account_notifications
|
||||||
current_account.notifications.without_suspended.browserable(
|
current_account.notifications.without_suspended.browserable(
|
||||||
types: Array(browserable_params[:types]),
|
types: Array(browserable_params[:types]),
|
||||||
|
|
|
@ -70,7 +70,13 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_functional!
|
def require_functional!
|
||||||
redirect_to edit_user_registration_path unless current_user.functional?
|
return if current_user.functional?
|
||||||
|
|
||||||
|
if current_user.confirmed?
|
||||||
|
redirect_to edit_user_registration_path
|
||||||
|
else
|
||||||
|
redirect_to auth_setup_path
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def skip_csrf_meta_tags?
|
def skip_csrf_meta_tags?
|
||||||
|
|
|
@ -142,4 +142,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
def set_cache_headers
|
def set_cache_headers
|
||||||
response.cache_control.replace(private: true, no_store: true)
|
response.cache_control.replace(private: true, no_store: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def is_flashing_format? # rubocop:disable Naming/PredicateName
|
||||||
|
if params[:action] == 'create'
|
||||||
|
false # Disable flash messages for sign-up
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,7 +25,7 @@ module Localized
|
||||||
end
|
end
|
||||||
|
|
||||||
def available_locale_or_nil(locale_name)
|
def available_locale_or_nil(locale_name)
|
||||||
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
|
locale_name.to_sym if locale_name.respond_to?(:to_sym) && I18n.available_locales.include?(locale_name.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
def content_locale
|
def content_locale
|
||||||
|
|
|
@ -7,6 +7,7 @@ module WebAppControllerConcern
|
||||||
vary_by 'Accept, Accept-Language, Cookie'
|
vary_by 'Accept, Accept-Language, Cookie'
|
||||||
|
|
||||||
before_action :redirect_unauthenticated_to_permalinks!
|
before_action :redirect_unauthenticated_to_permalinks!
|
||||||
|
before_action :set_referer_header
|
||||||
|
|
||||||
content_security_policy do |p|
|
content_security_policy do |p|
|
||||||
policy = ContentSecurityPolicy.new
|
policy = ContentSecurityPolicy.new
|
||||||
|
@ -41,4 +42,10 @@ module WebAppControllerConcern
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def set_referer_header
|
||||||
|
response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'origin' : 'same-origin')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
class CustomCssController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||||
before_action :set_user_roles
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
expires_in 3.minutes, public: true
|
expires_in 1.month, public: true
|
||||||
render content_type: 'text/css'
|
render content_type: 'text/css'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -14,8 +12,4 @@ class CustomCssController < ActionController::Base # rubocop:disable Rails/Appli
|
||||||
Setting.custom_css
|
Setting.custom_css
|
||||||
end
|
end
|
||||||
helper_method :custom_css_styles
|
helper_method :custom_css_styles
|
||||||
|
|
||||||
def set_user_roles
|
|
||||||
@user_roles = UserRole.providing_styles
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
11
app/controllers/terms_of_service_controller.rb
Normal file
11
app/controllers/terms_of_service_controller.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TermsOfServiceController < ApplicationController
|
||||||
|
include WebAppControllerConcern
|
||||||
|
|
||||||
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
|
def show
|
||||||
|
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
|
||||||
|
end
|
||||||
|
end
|
|
@ -238,6 +238,14 @@ module ApplicationHelper
|
||||||
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def app_store_url_ios
|
||||||
|
'https://apps.apple.com/app/mastodon-for-iphone-and-ipad/id1571998974'
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_url_android
|
||||||
|
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def storage_host_var
|
def storage_host_var
|
||||||
|
|
|
@ -64,6 +64,10 @@ module FormattingHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def markdown(text)
|
||||||
|
Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def wrapped_status_content_format(status)
|
def wrapped_status_content_format(status)
|
||||||
|
|
|
@ -23,8 +23,31 @@ module ThemeHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def custom_stylesheet
|
||||||
|
if active_custom_stylesheet.present?
|
||||||
|
stylesheet_link_tag(
|
||||||
|
custom_css_path(active_custom_stylesheet),
|
||||||
|
host: root_url,
|
||||||
|
media: :all,
|
||||||
|
skip_pipeline: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def active_custom_stylesheet
|
||||||
|
if cached_custom_css_digest.present?
|
||||||
|
[:custom, cached_custom_css_digest.to_s.first(8)]
|
||||||
|
.compact_blank
|
||||||
|
.join('-')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cached_custom_css_digest
|
||||||
|
Rails.cache.read(:setting_digest_custom_css)
|
||||||
|
end
|
||||||
|
|
||||||
def theme_color_for(theme)
|
def theme_color_for(theme)
|
||||||
theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark]
|
theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark]
|
||||||
end
|
end
|
||||||
|
|
|
@ -60,6 +60,10 @@ window.addEventListener('message', (e) => {
|
||||||
|
|
||||||
const data = e.data;
|
const data = e.data;
|
||||||
|
|
||||||
|
// Only set overflow to `hidden` once we got the expected `message` so the post can still be scrolled if
|
||||||
|
// embedded without parent Javascript support
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
// We use a timeout to allow for the React page to render before calculating the height
|
// We use a timeout to allow for the React page to render before calculating the height
|
||||||
afterInitialRender(() => {
|
afterInitialRender(() => {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
|
|
|
@ -119,7 +119,11 @@ function loaded() {
|
||||||
formattedContent = dateFormat.format(datetime);
|
formattedContent = dateFormat.format(datetime);
|
||||||
}
|
}
|
||||||
|
|
||||||
content.title = formattedContent;
|
const timeGiven = content.dateTime.includes('T');
|
||||||
|
content.title = timeGiven
|
||||||
|
? dateTimeFormat.format(datetime)
|
||||||
|
: dateFormat.format(datetime);
|
||||||
|
|
||||||
content.textContent = formattedContent;
|
content.textContent = formattedContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { openURL } from 'mastodon/actions/search';
|
import { openURL } from 'mastodon/actions/search';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -28,12 +30,22 @@ export const useLinks = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMentionClick = useCallback(
|
const handleMentionClick = useCallback(
|
||||||
(element: HTMLAnchorElement) => {
|
async (element: HTMLAnchorElement) => {
|
||||||
dispatch(
|
const result = await dispatch(openURL({ url: element.href }));
|
||||||
openURL(element.href, history, () => {
|
|
||||||
|
if (isFulfilled(result)) {
|
||||||
|
if (result.payload.accounts[0]) {
|
||||||
|
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||||
|
} else if (result.payload.statuses[0]) {
|
||||||
|
history.push(
|
||||||
|
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
window.location.href = element.href;
|
window.location.href = element.href;
|
||||||
}),
|
}
|
||||||
);
|
} else if (isRejected(result)) {
|
||||||
|
window.location.href = element.href;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch, history],
|
[dispatch, history],
|
||||||
);
|
);
|
||||||
|
@ -48,7 +60,7 @@ export const useLinks = () => {
|
||||||
|
|
||||||
if (isMentionClick(target)) {
|
if (isMentionClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleMentionClick(target);
|
void handleMentionClick(target);
|
||||||
} else if (isHashtagClick(target)) {
|
} else if (isHashtagClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleHashtagClick(target);
|
handleHashtagClick(target);
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
|
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||||
|
|
||||||
import { importAccounts } from '../accounts_typed';
|
import { importAccounts } from '../accounts_typed';
|
||||||
|
|
||||||
import { normalizeStatus, normalizePoll } from './normalizer';
|
import { normalizeStatus } from './normalizer';
|
||||||
|
import { importPolls } from './polls';
|
||||||
|
|
||||||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||||
export const POLLS_IMPORT = 'POLLS_IMPORT';
|
|
||||||
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
export const FILTERS_IMPORT = 'FILTERS_IMPORT';
|
||||||
|
|
||||||
function pushUnique(array, object) {
|
function pushUnique(array, object) {
|
||||||
|
@ -25,10 +27,6 @@ export function importFilters(filters) {
|
||||||
return { type: FILTERS_IMPORT, filters };
|
return { type: FILTERS_IMPORT, filters };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importPolls(polls) {
|
|
||||||
return { type: POLLS_IMPORT, polls };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importFetchedAccount(account) {
|
export function importFetchedAccount(account) {
|
||||||
return importFetchedAccounts([account]);
|
return importFetchedAccounts([account]);
|
||||||
}
|
}
|
||||||
|
@ -73,7 +71,7 @@ export function importFetchedStatuses(statuses) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.poll?.id) {
|
if (status.poll?.id) {
|
||||||
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
|
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls.get(status.poll.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.card) {
|
if (status.card) {
|
||||||
|
@ -83,15 +81,9 @@ export function importFetchedStatuses(statuses) {
|
||||||
|
|
||||||
statuses.forEach(processStatus);
|
statuses.forEach(processStatus);
|
||||||
|
|
||||||
dispatch(importPolls(polls));
|
dispatch(importPolls({ polls }));
|
||||||
dispatch(importFetchedAccounts(accounts));
|
dispatch(importFetchedAccounts(accounts));
|
||||||
dispatch(importStatuses(normalStatuses));
|
dispatch(importStatuses(normalStatuses));
|
||||||
dispatch(importFilters(filters));
|
dispatch(importFilters(filters));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importFetchedPoll(poll) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))]));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
|
||||||
|
|
||||||
import emojify from '../../features/emoji/emoji';
|
import emojify from '../../features/emoji/emoji';
|
||||||
import { expandSpoilers } from '../../initial_state';
|
import { expandSpoilers } from '../../initial_state';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
|
|
||||||
obj[`:${emoji.shortcode}:`] = emoji;
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
export function searchTextFromRawStatus (status) {
|
export function searchTextFromRawStatus (status) {
|
||||||
const spoilerText = status.spoiler_text || '';
|
const spoilerText = status.spoiler_text || '';
|
||||||
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||||
|
@ -112,38 +109,6 @@ export function normalizeStatusTranslation(translation, status) {
|
||||||
return normalTranslation;
|
return normalTranslation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePoll(poll, normalOldPoll) {
|
|
||||||
const normalPoll = { ...poll };
|
|
||||||
const emojiMap = makeEmojiMap(poll.emojis);
|
|
||||||
|
|
||||||
normalPoll.options = poll.options.map((option, index) => {
|
|
||||||
const normalOption = {
|
|
||||||
...option,
|
|
||||||
voted: poll.own_votes && poll.own_votes.includes(index),
|
|
||||||
titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) {
|
|
||||||
normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalOption;
|
|
||||||
});
|
|
||||||
|
|
||||||
return normalPoll;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePollOptionTranslation(translation, poll) {
|
|
||||||
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
|
|
||||||
|
|
||||||
const normalTranslation = {
|
|
||||||
...translation,
|
|
||||||
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
|
|
||||||
};
|
|
||||||
|
|
||||||
return normalTranslation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAnnouncement(announcement) {
|
export function normalizeAnnouncement(announcement) {
|
||||||
const normalAnnouncement = { ...announcement };
|
const normalAnnouncement = { ...announcement };
|
||||||
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
|
||||||
|
|
7
app/javascript/mastodon/actions/importer/polls.ts
Normal file
7
app/javascript/mastodon/actions/importer/polls.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import type { Poll } from 'mastodon/models/poll';
|
||||||
|
|
||||||
|
export const importPolls = createAction<{ polls: Poll[] }>(
|
||||||
|
'poll/importMultiple',
|
||||||
|
);
|
|
@ -155,7 +155,7 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||||
|
|
||||||
const showInColumn =
|
const showInColumn =
|
||||||
activeFilter === 'all'
|
activeFilter === 'all'
|
||||||
? notificationShows[notification.type]
|
? notificationShows[notification.type] !== false
|
||||||
: activeFilter === notification.type;
|
: activeFilter === notification.type;
|
||||||
|
|
||||||
if (!showInColumn) return;
|
if (!showInColumn) return;
|
||||||
|
|
|
@ -1,69 +1,31 @@
|
||||||
import { IntlMessageFormat } from 'intl-messageformat';
|
import { IntlMessageFormat } from 'intl-messageformat';
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
|
|
||||||
import { compareId } from 'mastodon/compare_id';
|
|
||||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
|
||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
import { requestNotificationPermission } from '../utils/notifications';
|
import { requestNotificationPermission } from '../utils/notifications';
|
||||||
|
|
||||||
import { fetchFollowRequests } from './accounts';
|
import { fetchFollowRequests } from './accounts';
|
||||||
import {
|
import {
|
||||||
importFetchedAccount,
|
importFetchedAccount,
|
||||||
importFetchedAccounts,
|
|
||||||
importFetchedStatus,
|
|
||||||
importFetchedStatuses,
|
|
||||||
} from './importer';
|
} from './importer';
|
||||||
import { submitMarkers } from './markers';
|
import { submitMarkers } from './markers';
|
||||||
import { notificationsUpdate } from "./notifications_typed";
|
import { notificationsUpdate } from "./notifications_typed";
|
||||||
import { register as registerPushNotifications } from './push_notifications';
|
import { register as registerPushNotifications } from './push_notifications';
|
||||||
import { saveSettings } from './settings';
|
|
||||||
|
|
||||||
export * from "./notifications_typed";
|
export * from "./notifications_typed";
|
||||||
|
|
||||||
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
|
|
||||||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
|
|
||||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
|
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
|
||||||
|
|
||||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
|
||||||
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
|
||||||
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
|
||||||
|
|
||||||
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||||
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
|
|
||||||
|
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
|
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
|
|
||||||
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
|
|
||||||
|
|
||||||
defineMessages({
|
defineMessages({
|
||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loadPending = () => ({
|
|
||||||
type: NOTIFICATIONS_LOAD_PENDING,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
|
||||||
const showInColumn = activeFilter === 'all' ? getState().getIn(['settings', 'notifications', 'shows', notification.type], true) : activeFilter === notification.type;
|
|
||||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||||
|
|
||||||
|
@ -85,25 +47,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
|
|
||||||
dispatch(submitMarkers());
|
dispatch(submitMarkers());
|
||||||
|
|
||||||
if (showInColumn) {
|
// `notificationsUpdate` is still used in `user_lists` and `relationships` reducers
|
||||||
dispatch(importFetchedAccount(notification.account));
|
dispatch(importFetchedAccount(notification.account));
|
||||||
|
dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered}));
|
||||||
if (notification.status) {
|
|
||||||
dispatch(importFetchedStatus(notification.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.report) {
|
|
||||||
dispatch(importFetchedAccount(notification.report.target_account));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
|
|
||||||
} else if (playSound && !filtered) {
|
|
||||||
dispatch({
|
|
||||||
type: NOTIFICATIONS_UPDATE_NOOP,
|
|
||||||
meta: { sound: 'boop' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop notifications
|
// Desktop notifications
|
||||||
if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
|
if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
|
||||||
|
@ -120,141 +66,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
|
||||||
|
|
||||||
const excludeTypesFromFilter = filter => {
|
|
||||||
const allTypes = ImmutableList([
|
|
||||||
'follow',
|
|
||||||
'follow_request',
|
|
||||||
'favourite',
|
|
||||||
'reblog',
|
|
||||||
'mention',
|
|
||||||
'poll',
|
|
||||||
'status',
|
|
||||||
'update',
|
|
||||||
'admin.sign_up',
|
|
||||||
'admin.report',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return allTypes.filterNot(item => item === filter).toJS();
|
|
||||||
};
|
|
||||||
|
|
||||||
const noOp = () => {};
|
const noOp = () => {};
|
||||||
|
|
||||||
let expandNotificationsController = new AbortController();
|
|
||||||
|
|
||||||
export function expandNotifications({ maxId = undefined, forceLoad = false }) {
|
|
||||||
return async (dispatch, getState) => {
|
|
||||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
|
||||||
const notifications = getState().get('notifications');
|
|
||||||
const isLoadingMore = !!maxId;
|
|
||||||
|
|
||||||
if (notifications.get('isLoading')) {
|
|
||||||
if (forceLoad) {
|
|
||||||
expandNotificationsController.abort();
|
|
||||||
expandNotificationsController = new AbortController();
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
max_id: maxId,
|
|
||||||
exclude_types: activeFilter === 'all'
|
|
||||||
? excludeTypesFromSettings(getState())
|
|
||||||
: excludeTypesFromFilter(activeFilter),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
|
|
||||||
const a = notifications.getIn(['pendingItems', 0, 'id']);
|
|
||||||
const b = notifications.getIn(['items', 0, 'id']);
|
|
||||||
|
|
||||||
if (a && b && compareId(a, b) > 0) {
|
|
||||||
params.since_id = a;
|
|
||||||
} else {
|
|
||||||
params.since_id = b || a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoadingRecent = !!params.since_id;
|
|
||||||
|
|
||||||
dispatch(expandNotificationsRequest(isLoadingMore));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal });
|
|
||||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
|
||||||
|
|
||||||
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
|
|
||||||
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
|
|
||||||
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
|
|
||||||
|
|
||||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
|
||||||
dispatch(submitMarkers());
|
|
||||||
} catch(error) {
|
|
||||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandNotificationsRequest(isLoadingMore) {
|
|
||||||
return {
|
|
||||||
type: NOTIFICATIONS_EXPAND_REQUEST,
|
|
||||||
skipLoading: !isLoadingMore,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
|
|
||||||
return {
|
|
||||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
|
||||||
notifications,
|
|
||||||
next,
|
|
||||||
isLoadingRecent: isLoadingRecent,
|
|
||||||
usePendingItems,
|
|
||||||
skipLoading: !isLoadingMore,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandNotificationsFail(error, isLoadingMore) {
|
|
||||||
return {
|
|
||||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
skipLoading: !isLoadingMore,
|
|
||||||
skipAlert: !isLoadingMore || error.name === 'AbortError',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scrollTopNotifications(top) {
|
|
||||||
return {
|
|
||||||
type: NOTIFICATIONS_SCROLL_TOP,
|
|
||||||
top,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setFilter (filterType) {
|
|
||||||
return dispatch => {
|
|
||||||
dispatch({
|
|
||||||
type: NOTIFICATIONS_FILTER_SET,
|
|
||||||
path: ['notifications', 'quickFilter', 'active'],
|
|
||||||
value: filterType,
|
|
||||||
});
|
|
||||||
dispatch(expandNotifications({ forceLoad: true }));
|
|
||||||
dispatch(saveSettings());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mountNotifications = () => ({
|
|
||||||
type: NOTIFICATIONS_MOUNT,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unmountNotifications = () => ({
|
|
||||||
type: NOTIFICATIONS_UNMOUNT,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const markNotificationsAsRead = () => ({
|
|
||||||
type: NOTIFICATIONS_MARK_AS_READ,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Browser support
|
// Browser support
|
||||||
export function setupBrowserNotifications() {
|
export function setupBrowserNotifications() {
|
||||||
return dispatch => {
|
return dispatch => {
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { createAppAsyncThunk } from 'mastodon/store';
|
|
||||||
|
|
||||||
import { fetchNotifications } from './notification_groups';
|
|
||||||
|
|
||||||
export const initializeNotifications = createAppAsyncThunk(
|
|
||||||
'notifications/initialize',
|
|
||||||
(_, { dispatch }) => {
|
|
||||||
void dispatch(fetchNotifications());
|
|
||||||
},
|
|
||||||
);
|
|
|
@ -9,7 +9,6 @@ export const notificationsUpdate = createAction(
|
||||||
...args
|
...args
|
||||||
}: {
|
}: {
|
||||||
notification: ApiNotificationJSON;
|
notification: ApiNotificationJSON;
|
||||||
usePendingItems: boolean;
|
|
||||||
playSound: boolean;
|
playSound: boolean;
|
||||||
}) => ({
|
}) => ({
|
||||||
payload: args,
|
payload: args,
|
||||||
|
|
|
@ -1,61 +0,0 @@
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
import { importFetchedPoll } from './importer';
|
|
||||||
|
|
||||||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
|
|
||||||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
|
|
||||||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
|
|
||||||
|
|
||||||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
|
|
||||||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
|
|
||||||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const vote = (pollId, choices) => (dispatch) => {
|
|
||||||
dispatch(voteRequest());
|
|
||||||
|
|
||||||
api().post(`/api/v1/polls/${pollId}/votes`, { choices })
|
|
||||||
.then(({ data }) => {
|
|
||||||
dispatch(importFetchedPoll(data));
|
|
||||||
dispatch(voteSuccess(data));
|
|
||||||
})
|
|
||||||
.catch(err => dispatch(voteFail(err)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchPoll = pollId => (dispatch) => {
|
|
||||||
dispatch(fetchPollRequest());
|
|
||||||
|
|
||||||
api().get(`/api/v1/polls/${pollId}`)
|
|
||||||
.then(({ data }) => {
|
|
||||||
dispatch(importFetchedPoll(data));
|
|
||||||
dispatch(fetchPollSuccess(data));
|
|
||||||
})
|
|
||||||
.catch(err => dispatch(fetchPollFail(err)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const voteRequest = () => ({
|
|
||||||
type: POLL_VOTE_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const voteSuccess = poll => ({
|
|
||||||
type: POLL_VOTE_SUCCESS,
|
|
||||||
poll,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const voteFail = error => ({
|
|
||||||
type: POLL_VOTE_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchPollRequest = () => ({
|
|
||||||
type: POLL_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchPollSuccess = poll => ({
|
|
||||||
type: POLL_FETCH_SUCCESS,
|
|
||||||
poll,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchPollFail = error => ({
|
|
||||||
type: POLL_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
40
app/javascript/mastodon/actions/polls.ts
Normal file
40
app/javascript/mastodon/actions/polls.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { apiGetPoll, apiPollVote } from 'mastodon/api/polls';
|
||||||
|
import type { ApiPollJSON } from 'mastodon/api_types/polls';
|
||||||
|
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||||
|
import {
|
||||||
|
createAppAsyncThunk,
|
||||||
|
createDataLoadingThunk,
|
||||||
|
} from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { importPolls } from './importer/polls';
|
||||||
|
|
||||||
|
export const importFetchedPoll = createAppAsyncThunk(
|
||||||
|
'poll/importFetched',
|
||||||
|
(args: { poll: ApiPollJSON }, { dispatch, getState }) => {
|
||||||
|
const { poll } = args;
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
importPolls({
|
||||||
|
polls: [createPollFromServerJSON(poll, getState().polls.get(poll.id))],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const vote = createDataLoadingThunk(
|
||||||
|
'poll/vote',
|
||||||
|
({ pollId, choices }: { pollId: string; choices: string[] }) =>
|
||||||
|
apiPollVote(pollId, choices),
|
||||||
|
async (poll, { dispatch, discardLoadData }) => {
|
||||||
|
await dispatch(importFetchedPoll({ poll }));
|
||||||
|
return discardLoadData;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchPoll = createDataLoadingThunk(
|
||||||
|
'poll/fetch',
|
||||||
|
({ pollId }: { pollId: string }) => apiGetPoll(pollId),
|
||||||
|
async (poll, { dispatch }) => {
|
||||||
|
await dispatch(importFetchedPoll({ poll }));
|
||||||
|
},
|
||||||
|
);
|
|
@ -1,215 +0,0 @@
|
||||||
import { fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import { searchHistory } from 'mastodon/settings';
|
|
||||||
|
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
import { fetchRelationships } from './accounts';
|
|
||||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
|
||||||
|
|
||||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
|
||||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
|
||||||
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
|
||||||
|
|
||||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
|
||||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
|
||||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
|
||||||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
|
||||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
|
||||||
|
|
||||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
|
||||||
|
|
||||||
export function changeSearch(value) {
|
|
||||||
return {
|
|
||||||
type: SEARCH_CHANGE,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSearch() {
|
|
||||||
return {
|
|
||||||
type: SEARCH_CLEAR,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function submitSearch(type) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
const value = getState().getIn(['search', 'value']);
|
|
||||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
|
||||||
|
|
||||||
if (value.length === 0) {
|
|
||||||
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchSearchRequest(type));
|
|
||||||
|
|
||||||
api().get('/api/v2/search', {
|
|
||||||
params: {
|
|
||||||
q: value,
|
|
||||||
resolve: signedIn,
|
|
||||||
limit: 11,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
}).then(response => {
|
|
||||||
if (response.data.accounts) {
|
|
||||||
dispatch(importFetchedAccounts(response.data.accounts));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.statuses) {
|
|
||||||
dispatch(importFetchedStatuses(response.data.statuses));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
|
||||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
|
||||||
}).catch(error => {
|
|
||||||
dispatch(fetchSearchFail(error));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSearchRequest(searchType) {
|
|
||||||
return {
|
|
||||||
type: SEARCH_FETCH_REQUEST,
|
|
||||||
searchType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSearchSuccess(results, searchTerm, searchType) {
|
|
||||||
return {
|
|
||||||
type: SEARCH_FETCH_SUCCESS,
|
|
||||||
results,
|
|
||||||
searchType,
|
|
||||||
searchTerm,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSearchFail(error) {
|
|
||||||
return {
|
|
||||||
type: SEARCH_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const expandSearch = type => (dispatch, getState) => {
|
|
||||||
const value = getState().getIn(['search', 'value']);
|
|
||||||
const offset = getState().getIn(['search', 'results', type]).size - 1;
|
|
||||||
|
|
||||||
dispatch(expandSearchRequest(type));
|
|
||||||
|
|
||||||
api().get('/api/v2/search', {
|
|
||||||
params: {
|
|
||||||
q: value,
|
|
||||||
type,
|
|
||||||
offset,
|
|
||||||
limit: 11,
|
|
||||||
},
|
|
||||||
}).then(({ data }) => {
|
|
||||||
if (data.accounts) {
|
|
||||||
dispatch(importFetchedAccounts(data.accounts));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.statuses) {
|
|
||||||
dispatch(importFetchedStatuses(data.statuses));
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(expandSearchSuccess(data, value, type));
|
|
||||||
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
|
||||||
}).catch(error => {
|
|
||||||
dispatch(expandSearchFail(error));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const expandSearchRequest = (searchType) => ({
|
|
||||||
type: SEARCH_EXPAND_REQUEST,
|
|
||||||
searchType,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
|
||||||
type: SEARCH_EXPAND_SUCCESS,
|
|
||||||
results,
|
|
||||||
searchTerm,
|
|
||||||
searchType,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const expandSearchFail = error => ({
|
|
||||||
type: SEARCH_EXPAND_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const showSearch = () => ({
|
|
||||||
type: SEARCH_SHOW,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
|
|
||||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
|
||||||
|
|
||||||
if (!signedIn) {
|
|
||||||
if (onFailure) {
|
|
||||||
onFailure();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchSearchRequest());
|
|
||||||
|
|
||||||
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
|
||||||
if (response.data.accounts?.length > 0) {
|
|
||||||
dispatch(importFetchedAccounts(response.data.accounts));
|
|
||||||
history.push(`/@${response.data.accounts[0].acct}`);
|
|
||||||
} else if (response.data.statuses?.length > 0) {
|
|
||||||
dispatch(importFetchedStatuses(response.data.statuses));
|
|
||||||
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
|
||||||
} else if (onFailure) {
|
|
||||||
onFailure();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(fetchSearchSuccess(response.data, value));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchSearchFail(err));
|
|
||||||
|
|
||||||
if (onFailure) {
|
|
||||||
onFailure();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clickSearchResult = (q, type) => (dispatch, getState) => {
|
|
||||||
const previous = getState().getIn(['search', 'recent']);
|
|
||||||
|
|
||||||
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const me = getState().getIn(['meta', 'me']);
|
|
||||||
const current = previous.add(fromJS({ type, q })).takeLast(4);
|
|
||||||
|
|
||||||
searchHistory.set(me, current.toJS());
|
|
||||||
dispatch(updateSearchHistory(current));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const forgetSearchResult = q => (dispatch, getState) => {
|
|
||||||
const previous = getState().getIn(['search', 'recent']);
|
|
||||||
const me = getState().getIn(['meta', 'me']);
|
|
||||||
const current = previous.filterNot(result => result.get('q') === q);
|
|
||||||
|
|
||||||
searchHistory.set(me, current.toJS());
|
|
||||||
dispatch(updateSearchHistory(current));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateSearchHistory = recent => ({
|
|
||||||
type: SEARCH_HISTORY_UPDATE,
|
|
||||||
recent,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const hydrateSearch = () => (dispatch, getState) => {
|
|
||||||
const me = getState().getIn(['meta', 'me']);
|
|
||||||
const history = searchHistory.get(me);
|
|
||||||
|
|
||||||
if (history !== null) {
|
|
||||||
dispatch(updateSearchHistory(history));
|
|
||||||
}
|
|
||||||
};
|
|
148
app/javascript/mastodon/actions/search.ts
Normal file
148
app/javascript/mastodon/actions/search.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { apiGetSearch } from 'mastodon/api/search';
|
||||||
|
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||||
|
import type {
|
||||||
|
RecentSearch,
|
||||||
|
SearchType as RecentSearchType,
|
||||||
|
} from 'mastodon/models/search';
|
||||||
|
import { searchHistory } from 'mastodon/settings';
|
||||||
|
import {
|
||||||
|
createDataLoadingThunk,
|
||||||
|
createAppAsyncThunk,
|
||||||
|
} from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
import { fetchRelationships } from './accounts';
|
||||||
|
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
|
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||||
|
|
||||||
|
export const submitSearch = createDataLoadingThunk(
|
||||||
|
'search/submit',
|
||||||
|
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
|
||||||
|
const signedIn = !!getState().meta.get('me');
|
||||||
|
|
||||||
|
return apiGetSearch({
|
||||||
|
q,
|
||||||
|
type,
|
||||||
|
resolve: signedIn,
|
||||||
|
limit: 11,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
if (data.accounts.length > 0) {
|
||||||
|
dispatch(importFetchedAccounts(data.accounts));
|
||||||
|
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.statuses.length > 0) {
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useLoadingBar: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const expandSearch = createDataLoadingThunk(
|
||||||
|
'search/expand',
|
||||||
|
async ({ type }: { type: ApiSearchType }, { getState }) => {
|
||||||
|
const q = getState().search.q;
|
||||||
|
const results = getState().search.results;
|
||||||
|
const offset = results?.[type].length;
|
||||||
|
|
||||||
|
return apiGetSearch({
|
||||||
|
q,
|
||||||
|
type,
|
||||||
|
limit: 10,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
if (data.accounts.length > 0) {
|
||||||
|
dispatch(importFetchedAccounts(data.accounts));
|
||||||
|
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.statuses.length > 0) {
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useLoadingBar: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const openURL = createDataLoadingThunk(
|
||||||
|
'search/openURL',
|
||||||
|
({ url }: { url: string }) =>
|
||||||
|
apiGetSearch({
|
||||||
|
q: url,
|
||||||
|
resolve: true,
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
|
(data, { dispatch }) => {
|
||||||
|
if (data.accounts.length > 0) {
|
||||||
|
dispatch(importFetchedAccounts(data.accounts));
|
||||||
|
} else if (data.statuses.length > 0) {
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
useLoadingBar: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clickSearchResult = createAppAsyncThunk(
|
||||||
|
'search/clickResult',
|
||||||
|
(
|
||||||
|
{ q, type }: { q: string; type?: RecentSearchType },
|
||||||
|
{ dispatch, getState },
|
||||||
|
) => {
|
||||||
|
const previous = getState().search.recent;
|
||||||
|
|
||||||
|
if (previous.some((x) => x.q === q && x.type === type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = getState().meta.get('me') as string;
|
||||||
|
const current = [{ type, q }, ...previous].slice(0, 4);
|
||||||
|
|
||||||
|
searchHistory.set(me, current);
|
||||||
|
dispatch(updateSearchHistory(current));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const forgetSearchResult = createAppAsyncThunk(
|
||||||
|
'search/forgetResult',
|
||||||
|
(q: string, { dispatch, getState }) => {
|
||||||
|
const previous = getState().search.recent;
|
||||||
|
const me = getState().meta.get('me') as string;
|
||||||
|
const current = previous.filter((result) => result.q !== q);
|
||||||
|
|
||||||
|
searchHistory.set(me, current);
|
||||||
|
dispatch(updateSearchHistory(current));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateSearchHistory = createAction<RecentSearch[]>(
|
||||||
|
'search/updateHistory',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const hydrateSearch = createAppAsyncThunk(
|
||||||
|
'search/hydrate',
|
||||||
|
(_args, { dispatch, getState }) => {
|
||||||
|
const me = getState().meta.get('me') as string;
|
||||||
|
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||||
|
|
||||||
|
if (history !== null) {
|
||||||
|
dispatch(updateSearchHistory(history));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
|
@ -1,4 +1,4 @@
|
||||||
import { Iterable, fromJS } from 'immutable';
|
import { fromJS, isIndexed } from 'immutable';
|
||||||
|
|
||||||
import { hydrateCompose } from './compose';
|
import { hydrateCompose } from './compose';
|
||||||
import { importFetchedAccounts } from './importer';
|
import { importFetchedAccounts } from './importer';
|
||||||
|
@ -9,8 +9,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
||||||
|
|
||||||
const convertState = rawState =>
|
const convertState = rawState =>
|
||||||
fromJS(rawState, (k, v) =>
|
fromJS(rawState, (k, v) =>
|
||||||
Iterable.isIndexed(v) ? v.toList() : v.toMap());
|
isIndexed(v) ? v.toList() : v.toMap());
|
||||||
|
|
||||||
|
|
||||||
export function hydrateStore(rawState) {
|
export function hydrateStore(rawState) {
|
||||||
return dispatch => {
|
return dispatch => {
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from './announcements';
|
} from './announcements';
|
||||||
import { updateConversations } from './conversations';
|
import { updateConversations } from './conversations';
|
||||||
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
|
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
|
||||||
import { updateNotifications, expandNotifications } from './notifications';
|
import { updateNotifications } from './notifications';
|
||||||
import { updateStatus } from './statuses';
|
import { updateStatus } from './statuses';
|
||||||
import {
|
import {
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
|
@ -107,9 +107,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'notifications_merged': {
|
case 'notifications_merged': {
|
||||||
const state = getState();
|
|
||||||
if (state.notifications.top || !state.notifications.mounted)
|
|
||||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
|
||||||
dispatch(refreshStaleNotificationGroups());
|
dispatch(refreshStaleNotificationGroups());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
|
||||||
export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
|
||||||
export const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||||
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||||
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
export const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||||
|
@ -12,39 +8,6 @@ export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUES
|
||||||
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||||
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
export const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||||
|
|
||||||
export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
|
||||||
export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
|
||||||
export const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
|
||||||
|
|
||||||
export const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
|
||||||
export const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
|
||||||
export const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
|
||||||
|
|
||||||
export const fetchHashtag = name => (dispatch) => {
|
|
||||||
dispatch(fetchHashtagRequest());
|
|
||||||
|
|
||||||
api().get(`/api/v1/tags/${name}`).then(({ data }) => {
|
|
||||||
dispatch(fetchHashtagSuccess(name, data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(fetchHashtagFail(err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchHashtagRequest = () => ({
|
|
||||||
type: HASHTAG_FETCH_REQUEST,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchHashtagSuccess = (name, tag) => ({
|
|
||||||
type: HASHTAG_FETCH_SUCCESS,
|
|
||||||
name,
|
|
||||||
tag,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchHashtagFail = error => ({
|
|
||||||
type: HASHTAG_FETCH_FAIL,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchFollowedHashtags = () => (dispatch) => {
|
export const fetchFollowedHashtags = () => (dispatch) => {
|
||||||
dispatch(fetchFollowedHashtagsRequest());
|
dispatch(fetchFollowedHashtagsRequest());
|
||||||
|
|
||||||
|
@ -116,57 +79,3 @@ export function expandFollowedHashtagsFail(error) {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const followHashtag = name => (dispatch) => {
|
|
||||||
dispatch(followHashtagRequest(name));
|
|
||||||
|
|
||||||
api().post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
|
||||||
dispatch(followHashtagSuccess(name, data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(followHashtagFail(name, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const followHashtagRequest = name => ({
|
|
||||||
type: HASHTAG_FOLLOW_REQUEST,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const followHashtagSuccess = (name, tag) => ({
|
|
||||||
type: HASHTAG_FOLLOW_SUCCESS,
|
|
||||||
name,
|
|
||||||
tag,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const followHashtagFail = (name, error) => ({
|
|
||||||
type: HASHTAG_FOLLOW_FAIL,
|
|
||||||
name,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unfollowHashtag = name => (dispatch) => {
|
|
||||||
dispatch(unfollowHashtagRequest(name));
|
|
||||||
|
|
||||||
api().post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
|
||||||
dispatch(unfollowHashtagSuccess(name, data));
|
|
||||||
}).catch(err => {
|
|
||||||
dispatch(unfollowHashtagFail(name, err));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const unfollowHashtagRequest = name => ({
|
|
||||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unfollowHashtagSuccess = (name, tag) => ({
|
|
||||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
|
||||||
name,
|
|
||||||
tag,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unfollowHashtagFail = (name, error) => ({
|
|
||||||
type: HASHTAG_UNFOLLOW_FAIL,
|
|
||||||
name,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
17
app/javascript/mastodon/actions/tags_typed.ts
Normal file
17
app/javascript/mastodon/actions/tags_typed.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags';
|
||||||
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
export const fetchHashtag = createDataLoadingThunk(
|
||||||
|
'tags/fetch',
|
||||||
|
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const followHashtag = createDataLoadingThunk(
|
||||||
|
'tags/follow',
|
||||||
|
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const unfollowHashtag = createDataLoadingThunk(
|
||||||
|
'tags/unfollow',
|
||||||
|
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
|
||||||
|
);
|
11
app/javascript/mastodon/api/instance.ts
Normal file
11
app/javascript/mastodon/api/instance.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type {
|
||||||
|
ApiTermsOfServiceJSON,
|
||||||
|
ApiPrivacyPolicyJSON,
|
||||||
|
} from 'mastodon/api_types/instance';
|
||||||
|
|
||||||
|
export const apiGetTermsOfService = () =>
|
||||||
|
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service');
|
||||||
|
|
||||||
|
export const apiGetPrivacyPolicy = () =>
|
||||||
|
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');
|
10
app/javascript/mastodon/api/polls.ts
Normal file
10
app/javascript/mastodon/api/polls.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { apiRequestGet, apiRequestPost } from 'mastodon/api';
|
||||||
|
import type { ApiPollJSON } from 'mastodon/api_types/polls';
|
||||||
|
|
||||||
|
export const apiGetPoll = (pollId: string) =>
|
||||||
|
apiRequestGet<ApiPollJSON>(`/v1/polls/${pollId}`);
|
||||||
|
|
||||||
|
export const apiPollVote = (pollId: string, choices: string[]) =>
|
||||||
|
apiRequestPost<ApiPollJSON>(`/v1/polls/${pollId}/votes`, {
|
||||||
|
choices,
|
||||||
|
});
|
16
app/javascript/mastodon/api/search.ts
Normal file
16
app/javascript/mastodon/api/search.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { apiRequestGet } from 'mastodon/api';
|
||||||
|
import type {
|
||||||
|
ApiSearchType,
|
||||||
|
ApiSearchResultsJSON,
|
||||||
|
} from 'mastodon/api_types/search';
|
||||||
|
|
||||||
|
export const apiGetSearch = (params: {
|
||||||
|
q: string;
|
||||||
|
resolve?: boolean;
|
||||||
|
type?: ApiSearchType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) =>
|
||||||
|
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
|
||||||
|
...params,
|
||||||
|
});
|
11
app/javascript/mastodon/api/tags.ts
Normal file
11
app/javascript/mastodon/api/tags.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||||
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
|
|
||||||
|
export const apiGetTag = (tagId: string) =>
|
||||||
|
apiRequestGet<ApiHashtagJSON>(`v1/tags/${tagId}`);
|
||||||
|
|
||||||
|
export const apiFollowTag = (tagId: string) =>
|
||||||
|
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/follow`);
|
||||||
|
|
||||||
|
export const apiUnfollowTag = (tagId: string) =>
|
||||||
|
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
|
9
app/javascript/mastodon/api_types/instance.ts
Normal file
9
app/javascript/mastodon/api_types/instance.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export interface ApiTermsOfServiceJSON {
|
||||||
|
updated_at: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPrivacyPolicyJSON {
|
||||||
|
updated_at: string;
|
||||||
|
content: string;
|
||||||
|
}
|
|
@ -18,6 +18,6 @@ export interface ApiPollJSON {
|
||||||
options: ApiPollOptionJSON[];
|
options: ApiPollOptionJSON[];
|
||||||
emojis: ApiCustomEmojiJSON[];
|
emojis: ApiCustomEmojiJSON[];
|
||||||
|
|
||||||
voted: boolean;
|
voted?: boolean;
|
||||||
own_votes: number[];
|
own_votes?: number[];
|
||||||
}
|
}
|
||||||
|
|
11
app/javascript/mastodon/api_types/search.ts
Normal file
11
app/javascript/mastodon/api_types/search.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import type { ApiAccountJSON } from './accounts';
|
||||||
|
import type { ApiStatusJSON } from './statuses';
|
||||||
|
import type { ApiHashtagJSON } from './tags';
|
||||||
|
|
||||||
|
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
|
||||||
|
|
||||||
|
export interface ApiSearchResultsJSON {
|
||||||
|
accounts: ApiAccountJSON[];
|
||||||
|
statuses: ApiStatusJSON[];
|
||||||
|
hashtags: ApiHashtagJSON[];
|
||||||
|
}
|
13
app/javascript/mastodon/api_types/tags.ts
Normal file
13
app/javascript/mastodon/api_types/tags.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
interface ApiHistoryJSON {
|
||||||
|
day: string;
|
||||||
|
accounts: string;
|
||||||
|
uses: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiHashtagJSON {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||||
|
following?: boolean;
|
||||||
|
}
|
|
@ -36,7 +36,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={attachment.get('id')}>
|
<li key={attachment.get('id')}>
|
||||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>
|
<a href={displayUrl} target='_blank' rel='noopener'>
|
||||||
{compact && <Icon id='link' icon={LinkIcon} />}
|
{compact && <Icon id='link' icon={LinkIcon} />}
|
||||||
{compact && ' ' }
|
{compact && ' ' }
|
||||||
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
|
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
|
||||||
|
|
|
@ -124,7 +124,7 @@ class DropdownMenu extends PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
|
<li className={classNames('dropdown-menu__item', { 'dropdown-menu__item--dangerous': dangerous })} key={`${text}-${i}`}>
|
||||||
<a href={href} target={target} data-method={method} rel='noopener noreferrer' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex={0} ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -98,7 +98,7 @@ export default class ErrorBoundary extends PureComponent {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
|
|
@ -88,7 +88,7 @@ export const FollowButton: React.FC<{
|
||||||
<a
|
<a
|
||||||
href='/settings/profile'
|
href='/settings/profile'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer noopener'
|
rel='noopener'
|
||||||
className='button button-secondary'
|
className='button button-secondary'
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
|
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
|
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
|
||||||
|
|
||||||
interface SilentErrorBoundaryProps {
|
interface SilentErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CompatibilityHashtag: React.FC<{
|
||||||
|
hashtag: HashtagType;
|
||||||
|
}> = ({ hashtag }) => (
|
||||||
|
<Hashtag
|
||||||
|
name={hashtag.name}
|
||||||
|
to={`/tags/${hashtag.name}`}
|
||||||
|
people={
|
||||||
|
(hashtag.history[0].accounts as unknown as number) * 1 +
|
||||||
|
((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1
|
||||||
|
}
|
||||||
|
history={hashtag.history
|
||||||
|
.map((day) => (day.uses as unknown as number) * 1)
|
||||||
|
.reverse()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export interface HashtagProps {
|
export interface HashtagProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
|
|
|
@ -106,7 +106,7 @@ class Item extends PureComponent {
|
||||||
if (attachment.get('type') === 'unknown') {
|
if (attachment.get('type') === 'unknown') {
|
||||||
return (
|
return (
|
||||||
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
|
||||||
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
|
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener'>
|
||||||
<Blurhash
|
<Blurhash
|
||||||
hash={attachment.get('blurhash')}
|
hash={attachment.get('blurhash')}
|
||||||
className='media-gallery__preview'
|
className='media-gallery__preview'
|
||||||
|
@ -138,7 +138,7 @@ class Item extends PureComponent {
|
||||||
href={attachment.get('remote_url') || originalUrl}
|
href={attachment.get('remote_url') || originalUrl}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
|
|
|
@ -33,15 +33,10 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
|
|
||||||
obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
class Poll extends ImmutablePureComponent {
|
class Poll extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
poll: ImmutablePropTypes.map.isRequired,
|
poll: ImmutablePropTypes.record.isRequired,
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
lang: PropTypes.string,
|
lang: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
@ -150,7 +145,7 @@ class Poll extends ImmutablePureComponent {
|
||||||
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
|
||||||
|
|
||||||
if (!titleHtml) {
|
if (!titleHtml) {
|
||||||
const emojiMap = makeEmojiMap(poll);
|
const emojiMap = emojiMap(poll);
|
||||||
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import illustration from '@/images/elephant_ui_working.svg';
|
|
||||||
|
|
||||||
const RegenerationIndicator = () => (
|
|
||||||
<div className='regeneration-indicator'>
|
|
||||||
<div className='regeneration-indicator__figure'>
|
|
||||||
<img src={illustration} alt='' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='regeneration-indicator__label'>
|
|
||||||
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' />
|
|
||||||
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default RegenerationIndicator;
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { GIF } from './gif';
|
||||||
|
|
||||||
|
export const RegenerationIndicator: React.FC = () => (
|
||||||
|
<div className='regeneration-indicator'>
|
||||||
|
<GIF
|
||||||
|
src='/loading.gif'
|
||||||
|
staticSrc='/loading.png'
|
||||||
|
className='regeneration-indicator__figure'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='regeneration-indicator__label'>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage
|
||||||
|
id='regeneration_indicator.preparing_your_home_feed'
|
||||||
|
defaultMessage='Preparing your home feed…'
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
<FormattedMessage
|
||||||
|
id='regeneration_indicator.please_stand_by'
|
||||||
|
defaultMessage='Please stand by.'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -1,6 +1,6 @@
|
||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
|
|
||||||
import type { IntlShape } from 'react-intl';
|
import type { MessageDescriptor, PrimitiveType, IntlShape } from 'react-intl';
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -102,7 +102,13 @@ const getUnitDelay = (units: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const timeAgoString = (
|
export const timeAgoString = (
|
||||||
intl: Pick<IntlShape, 'formatDate' | 'formatMessage'>,
|
intl: {
|
||||||
|
formatDate: IntlShape['formatDate'];
|
||||||
|
formatMessage: (
|
||||||
|
{ id, defaultMessage }: MessageDescriptor,
|
||||||
|
values?: Record<string, PrimitiveType>,
|
||||||
|
) => string;
|
||||||
|
},
|
||||||
date: Date,
|
date: Date,
|
||||||
now: number,
|
now: number,
|
||||||
year: number,
|
year: number,
|
||||||
|
|
|
@ -42,7 +42,7 @@ class ServerBanner extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<div className='server-banner'>
|
<div className='server-banner'>
|
||||||
<div className='server-banner__introduction'>
|
<div className='server-banner__introduction'>
|
||||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank' rel='noopener'>Mastodon</a> }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link to='/about'>
|
<Link to='/about'>
|
||||||
|
|
|
@ -167,7 +167,12 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleClick = e => {
|
handleClick = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleHotkeyOpen(e);
|
|
||||||
|
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
|
||||||
|
this._openStatus();
|
||||||
|
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
|
||||||
|
this._openStatus(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMouseUp = e => {
|
handleMouseUp = e => {
|
||||||
|
@ -275,7 +280,11 @@ class Status extends ImmutablePureComponent {
|
||||||
this.props.onMention(this._properStatus().get('account'));
|
this.props.onMention(this._properStatus().get('account'));
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyOpen = (e) => {
|
handleHotkeyOpen = () => {
|
||||||
|
this._openStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
_openStatus = (newTab = false) => {
|
||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
this.props.onClick();
|
this.props.onClick();
|
||||||
return;
|
return;
|
||||||
|
@ -290,10 +299,10 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`;
|
const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`;
|
||||||
|
|
||||||
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
|
if (newTab) {
|
||||||
|
window.open(path, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
history.push(path);
|
history.push(path);
|
||||||
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
|
|
||||||
window.open(path, '_blank', 'noreferrer noopener');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -384,6 +393,7 @@ class Status extends ImmutablePureComponent {
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||||
openMedia: this.handleHotkeyOpenMedia,
|
openMedia: this.handleHotkeyOpenMedia,
|
||||||
|
onTranslate: this.handleTranslate,
|
||||||
};
|
};
|
||||||
|
|
||||||
let media, statusAvatar, prepend, rebloggedByText;
|
let media, statusAvatar, prepend, rebloggedByText;
|
||||||
|
|
|
@ -47,6 +47,7 @@ const messages = defineMessages({
|
||||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
||||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||||
|
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
||||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||||
|
@ -271,8 +272,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
|
|
||||||
|
|
||||||
if (writtenByMe && pinnableStatus) {
|
if (writtenByMe && pinnableStatus) {
|
||||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||||
}
|
}
|
||||||
|
@ -371,6 +370,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
reblogIconComponent = RepeatDisabledIcon;
|
reblogIconComponent = RepeatDisabledIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||||
|
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -382,10 +384,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<DropdownMenuContainer
|
<DropdownMenuContainer
|
||||||
|
|
|
@ -38,7 +38,7 @@ class TranslateButton extends PureComponent {
|
||||||
|
|
||||||
if (translation) {
|
if (translation) {
|
||||||
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
|
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
|
||||||
const languageName = language ? language[2] : translation.get('detected_source_language');
|
const languageName = language ? language[1] : translation.get('detected_source_language');
|
||||||
const provider = translation.get('provider');
|
const provider = translation.get('provider');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,7 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||||
import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
|
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
|
||||||
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
||||||
|
|
||||||
import StatusContainer from '../containers/status_container';
|
import StatusContainer from '../containers/status_container';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||||
|
import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import Compose from 'mastodon/features/standalone/compose';
|
import Compose from 'mastodon/features/standalone/compose';
|
||||||
|
@ -13,6 +14,7 @@ if (initialState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dispatch(fetchCustomEmojis());
|
store.dispatch(fetchCustomEmojis());
|
||||||
|
store.dispatch(fetchServer());
|
||||||
|
|
||||||
const ComposeContainer = () => (
|
const ComposeContainer = () => (
|
||||||
<IntlProvider>
|
<IntlProvider>
|
||||||
|
|
|
@ -9,14 +9,14 @@ import Poll from 'mastodon/components/poll';
|
||||||
const mapDispatchToProps = (dispatch, { pollId }) => ({
|
const mapDispatchToProps = (dispatch, { pollId }) => ({
|
||||||
refresh: debounce(
|
refresh: debounce(
|
||||||
() => {
|
() => {
|
||||||
dispatch(fetchPoll(pollId));
|
dispatch(fetchPoll({ pollId }));
|
||||||
},
|
},
|
||||||
1000,
|
1000,
|
||||||
{ leading: true },
|
{ leading: true },
|
||||||
),
|
),
|
||||||
|
|
||||||
onVote (choices) {
|
onVote (choices) {
|
||||||
dispatch(vote(pollId, choices));
|
dispatch(vote({ pollId, choices }));
|
||||||
},
|
},
|
||||||
|
|
||||||
onInteractionModal (type, status) {
|
onInteractionModal (type, status) {
|
||||||
|
@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { pollId }) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, { pollId }) => ({
|
const mapStateToProps = (state, { pollId }) => ({
|
||||||
poll: state.getIn(['polls', pollId]),
|
poll: state.polls.get(pollId),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
|
export default connect(mapStateToProps, mapDispatchToProps)(Poll);
|
||||||
|
|
|
@ -18,7 +18,7 @@ import Column from 'mastodon/components/column';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.about', defaultMessage: 'About' },
|
title: { id: 'column.about', defaultMessage: 'About' },
|
||||||
|
@ -123,7 +123,7 @@ class About extends PureComponent {
|
||||||
<div className='about__header'>
|
<div className='about__header'>
|
||||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank'>Mastodon</a> }} /></p>
|
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='about__meta'>
|
<div className='about__meta'>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { NavLink, withRouter } from 'react-router-dom';
|
import { NavLink, withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
const link = e.currentTarget;
|
const link = e.currentTarget;
|
||||||
|
|
||||||
onOpenURL(link.href, history, () => {
|
onOpenURL(link.href).then((result) => {
|
||||||
window.location = link.href;
|
if (isFulfilled(result)) {
|
||||||
|
if (result.payload.accounts[0]) {
|
||||||
|
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||||
|
} else if (result.payload.statuses[0]) {
|
||||||
|
history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
|
||||||
|
} else {
|
||||||
|
window.location = link.href;
|
||||||
|
}
|
||||||
|
} else if (isRejected(result)) {
|
||||||
|
window.location = link.href;
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Nothing
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -421,7 +434,7 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
<div className='account__header__bar'>
|
<div className='account__header__bar'>
|
||||||
<div className='account__header__tabs'>
|
<div className='account__header__tabs'>
|
||||||
<a className='avatar' href={account.get('avatar')} rel='noopener noreferrer' target='_blank' onClick={this.handleAvatarClick}>
|
<a className='avatar' href={account.get('avatar')} rel='noopener' target='_blank' onClick={this.handleAvatarClick}>
|
||||||
<Avatar account={suspended || hidden ? undefined : account} size={90} />
|
<Avatar account={suspended || hidden ? undefined : account} size={90} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onOpenURL (url, routerHistory, onFailure) {
|
onOpenURL (url) {
|
||||||
dispatch(openURL(url, routerHistory, onFailure));
|
return dispatch(openURL({ url }));
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,402 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
import { domain, searchEnabled } from 'mastodon/initial_state';
|
|
||||||
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
|
||||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const labelForRecentSearch = search => {
|
|
||||||
switch(search.get('type')) {
|
|
||||||
case 'account':
|
|
||||||
return `@${search.get('q')}`;
|
|
||||||
case 'hashtag':
|
|
||||||
return `#${search.get('q')}`;
|
|
||||||
default:
|
|
||||||
return search.get('q');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class Search extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
recent: ImmutablePropTypes.orderedSet,
|
|
||||||
submitted: PropTypes.bool,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
onSubmit: PropTypes.func.isRequired,
|
|
||||||
onOpenURL: PropTypes.func.isRequired,
|
|
||||||
onClickSearchResult: PropTypes.func.isRequired,
|
|
||||||
onForgetSearchResult: PropTypes.func.isRequired,
|
|
||||||
onClear: PropTypes.func.isRequired,
|
|
||||||
onShow: PropTypes.func.isRequired,
|
|
||||||
openInRoute: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
singleColumn: PropTypes.bool,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
expanded: false,
|
|
||||||
selectedOption: -1,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
defaultOptions = [
|
|
||||||
{ key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
|
|
||||||
{ key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
|
|
||||||
{ key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
|
|
||||||
{ key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
|
|
||||||
{ key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
|
|
||||||
{ key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
|
|
||||||
{ key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
|
|
||||||
{ key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
|
|
||||||
];
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.searchForm = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
handleChange = ({ target }) => {
|
|
||||||
const { onChange } = this.props;
|
|
||||||
|
|
||||||
onChange(target.value);
|
|
||||||
|
|
||||||
this._calculateOptions(target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClear = e => {
|
|
||||||
const { value, submitted, onClear } = this.props;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (value.length > 0 || submitted) {
|
|
||||||
onClear();
|
|
||||||
this.setState({ options: [], selectedOption: -1 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = (e) => {
|
|
||||||
const { selectedOption } = this.state;
|
|
||||||
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
this._unfocus();
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (options.length > 0) {
|
|
||||||
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (options.length > 0) {
|
|
||||||
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (selectedOption === -1) {
|
|
||||||
this._submit();
|
|
||||||
} else if (options.length > 0) {
|
|
||||||
options[selectedOption].action(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'Delete':
|
|
||||||
if (selectedOption > -1 && options.length > 0) {
|
|
||||||
const search = options[selectedOption];
|
|
||||||
|
|
||||||
if (typeof search.forget === 'function') {
|
|
||||||
e.preventDefault();
|
|
||||||
search.forget(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFocus = () => {
|
|
||||||
const { onShow, singleColumn } = this.props;
|
|
||||||
|
|
||||||
this.setState({ expanded: true, selectedOption: -1 });
|
|
||||||
onShow();
|
|
||||||
|
|
||||||
if (this.searchForm && !singleColumn) {
|
|
||||||
const { left, right } = this.searchForm.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
|
||||||
this.searchForm.scrollIntoView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleBlur = () => {
|
|
||||||
this.setState({ expanded: false, selectedOption: -1 });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHashtagClick = () => {
|
|
||||||
const { value, onClickSearchResult, history } = this.props;
|
|
||||||
|
|
||||||
const query = value.trim().replace(/^#/, '');
|
|
||||||
|
|
||||||
history.push(`/tags/${query}`);
|
|
||||||
onClickSearchResult(query, 'hashtag');
|
|
||||||
this._unfocus();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAccountClick = () => {
|
|
||||||
const { value, onClickSearchResult, history } = this.props;
|
|
||||||
|
|
||||||
const query = value.trim().replace(/^@/, '');
|
|
||||||
|
|
||||||
history.push(`/@${query}`);
|
|
||||||
onClickSearchResult(query, 'account');
|
|
||||||
this._unfocus();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleURLClick = () => {
|
|
||||||
const { value, onOpenURL, history } = this.props;
|
|
||||||
|
|
||||||
onOpenURL(value, history);
|
|
||||||
this._unfocus();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleStatusSearch = () => {
|
|
||||||
this._submit('statuses');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleAccountSearch = () => {
|
|
||||||
this._submit('accounts');
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRecentSearchClick = search => {
|
|
||||||
const { onChange, history } = this.props;
|
|
||||||
|
|
||||||
if (search.get('type') === 'account') {
|
|
||||||
history.push(`/@${search.get('q')}`);
|
|
||||||
} else if (search.get('type') === 'hashtag') {
|
|
||||||
history.push(`/tags/${search.get('q')}`);
|
|
||||||
} else {
|
|
||||||
onChange(search.get('q'));
|
|
||||||
this._submit(search.get('type'));
|
|
||||||
}
|
|
||||||
|
|
||||||
this._unfocus();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleForgetRecentSearchClick = search => {
|
|
||||||
const { onForgetSearchResult } = this.props;
|
|
||||||
|
|
||||||
onForgetSearchResult(search.get('q'));
|
|
||||||
};
|
|
||||||
|
|
||||||
_unfocus () {
|
|
||||||
document.querySelector('.ui').parentElement.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_insertText (text) {
|
|
||||||
const { value, onChange } = this.props;
|
|
||||||
|
|
||||||
if (value === '') {
|
|
||||||
onChange(text);
|
|
||||||
} else if (value[value.length - 1] === ' ') {
|
|
||||||
onChange(`${value}${text}`);
|
|
||||||
} else {
|
|
||||||
onChange(`${value} ${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_submit (type) {
|
|
||||||
const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props;
|
|
||||||
|
|
||||||
onSubmit(type);
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
onClickSearchResult(value, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openInRoute) {
|
|
||||||
history.push('/search');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._unfocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_getOptions () {
|
|
||||||
const { options } = this.state;
|
|
||||||
|
|
||||||
if (options.length > 0) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { recent } = this.props;
|
|
||||||
|
|
||||||
return recent.toArray().map(search => ({
|
|
||||||
key: `${search.get('type')}/${search.get('q')}`,
|
|
||||||
|
|
||||||
label: labelForRecentSearch(search),
|
|
||||||
|
|
||||||
action: () => this.handleRecentSearchClick(search),
|
|
||||||
|
|
||||||
forget: e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.handleForgetRecentSearchClick(search);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
_calculateOptions (value) {
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
const options = [];
|
|
||||||
|
|
||||||
if (trimmedValue.length > 0) {
|
|
||||||
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
|
||||||
|
|
||||||
if (couldBeURL) {
|
|
||||||
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
|
|
||||||
}
|
|
||||||
|
|
||||||
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
|
|
||||||
|
|
||||||
if (couldBeHashtag) {
|
|
||||||
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
|
|
||||||
}
|
|
||||||
|
|
||||||
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
|
|
||||||
|
|
||||||
if (couldBeUsername) {
|
|
||||||
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
|
|
||||||
}
|
|
||||||
|
|
||||||
const couldBeStatusSearch = searchEnabled;
|
|
||||||
|
|
||||||
if (couldBeStatusSearch && signedIn) {
|
|
||||||
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
|
|
||||||
}
|
|
||||||
|
|
||||||
const couldBeUserSearch = true;
|
|
||||||
|
|
||||||
if (couldBeUserSearch) {
|
|
||||||
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ options });
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { intl, value, submitted, recent } = this.props;
|
|
||||||
const { expanded, options, selectedOption } = this.state;
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
|
|
||||||
const hasValue = value.length > 0 || submitted;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('search', { active: expanded })}>
|
|
||||||
<input
|
|
||||||
ref={this.setRef}
|
|
||||||
className='search__input'
|
|
||||||
type='text'
|
|
||||||
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
|
||||||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
|
||||||
value={value}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
onFocus={this.handleFocus}
|
|
||||||
onBlur={this.handleBlur}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
|
||||||
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
|
|
||||||
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='search__popout'>
|
|
||||||
{options.length === 0 && (
|
|
||||||
<>
|
|
||||||
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
|
|
||||||
|
|
||||||
<div className='search__popout__menu'>
|
|
||||||
{recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
|
|
||||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className='search__popout__menu__message'>
|
|
||||||
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{options.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
|
|
||||||
|
|
||||||
<div className='search__popout__menu'>
|
|
||||||
{options.map(({ key, label, action }, i) => (
|
|
||||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
|
|
||||||
|
|
||||||
{searchEnabled && signedIn ? (
|
|
||||||
<div className='search__popout__menu'>
|
|
||||||
{this.defaultOptions.map(({ key, label, action }, i) => (
|
|
||||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='search__popout__menu__message'>
|
|
||||||
{searchEnabled ? (
|
|
||||||
<FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withRouter(withIdentity(injectIntl(Search)));
|
|
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal file
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal file
|
@ -0,0 +1,593 @@
|
||||||
|
import { useCallback, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
defineMessages,
|
||||||
|
useIntl,
|
||||||
|
FormattedMessage,
|
||||||
|
FormattedList,
|
||||||
|
} from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||||
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||||
|
import {
|
||||||
|
clickSearchResult,
|
||||||
|
forgetSearchResult,
|
||||||
|
openURL,
|
||||||
|
} from 'mastodon/actions/search';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
import { domain, searchEnabled } from 'mastodon/initial_state';
|
||||||
|
import type { RecentSearch, SearchType } from 'mastodon/models/search';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||||
|
placeholderSignedIn: {
|
||||||
|
id: 'search.search_or_paste',
|
||||||
|
defaultMessage: 'Search or paste URL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelForRecentSearch = (search: RecentSearch) => {
|
||||||
|
switch (search.type) {
|
||||||
|
case 'account':
|
||||||
|
return `@${search.q}`;
|
||||||
|
case 'hashtag':
|
||||||
|
return `#${search.q}`;
|
||||||
|
default:
|
||||||
|
return search.q;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unfocus = () => {
|
||||||
|
document.querySelector('.ui')?.parentElement?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SearchOption {
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
action: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||||
|
forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Search: React.FC<{
|
||||||
|
singleColumn: boolean;
|
||||||
|
initialValue?: string;
|
||||||
|
}> = ({ singleColumn, initialValue }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const recent = useAppSelector((state) => state.search.recent);
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const history = useHistory();
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [value, setValue] = useState(initialValue ?? '');
|
||||||
|
const hasValue = value.length > 0;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [selectedOption, setSelectedOption] = useState(-1);
|
||||||
|
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
|
||||||
|
const searchOptions: SearchOption[] = [];
|
||||||
|
|
||||||
|
if (searchEnabled) {
|
||||||
|
searchOptions.push(
|
||||||
|
{
|
||||||
|
key: 'prompt-has',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>has:</mark>{' '}
|
||||||
|
<FormattedList
|
||||||
|
type='disjunction'
|
||||||
|
value={['media', 'poll', 'embed']}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('has:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-is',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>is:</mark>{' '}
|
||||||
|
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('is:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-language',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>language:</mark>{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.language_code'
|
||||||
|
defaultMessage='ISO language code'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('language:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-from',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>from:</mark>{' '}
|
||||||
|
<FormattedMessage id='search_popout.user' defaultMessage='user' />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('from:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-before',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>before:</mark>{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.specific_date'
|
||||||
|
defaultMessage='specific date'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('before:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-during',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>during:</mark>{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.specific_date'
|
||||||
|
defaultMessage='specific date'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('during:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-after',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>after:</mark>{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.specific_date'
|
||||||
|
defaultMessage='specific date'
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('after:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompt-in',
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<mark>in:</mark>{' '}
|
||||||
|
<FormattedList
|
||||||
|
type='disjunction'
|
||||||
|
value={['all', 'library', 'public']}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
action: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertText('in:');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentOptions: SearchOption[] = recent.map((search) => ({
|
||||||
|
key: `${search.type}/${search.q}`,
|
||||||
|
label: labelForRecentSearch(search),
|
||||||
|
action: () => {
|
||||||
|
setValue(search.q);
|
||||||
|
|
||||||
|
if (search.type === 'account') {
|
||||||
|
history.push(`/@${search.q}`);
|
||||||
|
} else if (search.type === 'hashtag') {
|
||||||
|
history.push(`/tags/${search.q}`);
|
||||||
|
} else {
|
||||||
|
const queryParams = new URLSearchParams({ q: search.q });
|
||||||
|
if (search.type) queryParams.set('type', search.type);
|
||||||
|
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
unfocus();
|
||||||
|
},
|
||||||
|
forget: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void dispatch(forgetSearchResult(search.q));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigableOptions = hasValue
|
||||||
|
? quickActions.concat(searchOptions)
|
||||||
|
: recentOptions.concat(quickActions, searchOptions);
|
||||||
|
|
||||||
|
const insertText = (text: string) => {
|
||||||
|
setValue((currentValue) => {
|
||||||
|
if (currentValue === '') {
|
||||||
|
return text;
|
||||||
|
} else if (currentValue.endsWith(' ')) {
|
||||||
|
return `${currentValue}${text}`;
|
||||||
|
} else {
|
||||||
|
return `${currentValue} ${text}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
(q: string, type?: SearchType) => {
|
||||||
|
void dispatch(clickSearchResult({ q, type }));
|
||||||
|
const queryParams = new URLSearchParams({ q });
|
||||||
|
if (type) queryParams.set('type', type);
|
||||||
|
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||||
|
unfocus();
|
||||||
|
},
|
||||||
|
[dispatch, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setValue(value);
|
||||||
|
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
const newQuickActions = [];
|
||||||
|
|
||||||
|
if (trimmedValue.length > 0) {
|
||||||
|
const couldBeURL =
|
||||||
|
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||||
|
|
||||||
|
if (couldBeURL) {
|
||||||
|
newQuickActions.push({
|
||||||
|
key: 'open-url',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.quick_action.open_url'
|
||||||
|
defaultMessage='Open URL in Mastodon'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: async () => {
|
||||||
|
const result = await dispatch(openURL({ url: trimmedValue }));
|
||||||
|
|
||||||
|
if (isFulfilled(result)) {
|
||||||
|
if (result.payload.accounts[0]) {
|
||||||
|
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||||
|
} else if (result.payload.statuses[0]) {
|
||||||
|
history.push(
|
||||||
|
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unfocus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const couldBeHashtag =
|
||||||
|
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
|
||||||
|
trimmedValue.match(HASHTAG_REGEX);
|
||||||
|
|
||||||
|
if (couldBeHashtag) {
|
||||||
|
newQuickActions.push({
|
||||||
|
key: 'go-to-hashtag',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.quick_action.go_to_hashtag'
|
||||||
|
defaultMessage='Go to hashtag {x}'
|
||||||
|
values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
const query = trimmedValue.replace(/^#/, '');
|
||||||
|
history.push(`/tags/${query}`);
|
||||||
|
void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
|
||||||
|
unfocus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
|
||||||
|
|
||||||
|
if (couldBeUsername) {
|
||||||
|
newQuickActions.push({
|
||||||
|
key: 'go-to-account',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.quick_action.go_to_account'
|
||||||
|
defaultMessage='Go to profile {x}'
|
||||||
|
values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
const query = trimmedValue.replace(/^@/, '');
|
||||||
|
history.push(`/@${query}`);
|
||||||
|
void dispatch(clickSearchResult({ q: query, type: 'account' }));
|
||||||
|
unfocus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const couldBeStatusSearch = searchEnabled;
|
||||||
|
|
||||||
|
if (couldBeStatusSearch && signedIn) {
|
||||||
|
newQuickActions.push({
|
||||||
|
key: 'status-search',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.quick_action.status_search'
|
||||||
|
defaultMessage='Posts matching {x}'
|
||||||
|
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
submit(trimmedValue, 'statuses');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newQuickActions.push({
|
||||||
|
key: 'account-search',
|
||||||
|
label: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.quick_action.account_search'
|
||||||
|
defaultMessage='Profiles matching {x}'
|
||||||
|
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
submit(trimmedValue, 'accounts');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuickActions(newQuickActions);
|
||||||
|
},
|
||||||
|
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setValue('');
|
||||||
|
setQuickActions([]);
|
||||||
|
setSelectedOption(-1);
|
||||||
|
}, [setValue, setQuickActions, setSelectedOption]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
unfocus();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (navigableOptions.length > 0) {
|
||||||
|
setSelectedOption(
|
||||||
|
Math.min(selectedOption + 1, navigableOptions.length - 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (navigableOptions.length > 0) {
|
||||||
|
setSelectedOption(Math.max(selectedOption - 1, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (selectedOption === -1) {
|
||||||
|
submit(value);
|
||||||
|
} else if (navigableOptions.length > 0) {
|
||||||
|
navigableOptions[selectedOption]?.action(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'Delete':
|
||||||
|
if (selectedOption > -1 && navigableOptions.length > 0) {
|
||||||
|
const search = navigableOptions[selectedOption];
|
||||||
|
|
||||||
|
if (typeof search?.forget === 'function') {
|
||||||
|
e.preventDefault();
|
||||||
|
search.forget(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setExpanded(true);
|
||||||
|
setSelectedOption(-1);
|
||||||
|
|
||||||
|
if (searchInputRef.current && !singleColumn) {
|
||||||
|
const { left, right } = searchInputRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (
|
||||||
|
left < 0 ||
|
||||||
|
right > (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
) {
|
||||||
|
searchInputRef.current.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setExpanded(false);
|
||||||
|
setSelectedOption(-1);
|
||||||
|
}, [setExpanded, setSelectedOption]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={classNames('search', { active: expanded })}>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
className='search__input'
|
||||||
|
type='text'
|
||||||
|
placeholder={intl.formatMessage(
|
||||||
|
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||||
|
)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type='button' className='search__icon' onClick={handleClear}>
|
||||||
|
<Icon
|
||||||
|
id='search'
|
||||||
|
icon={SearchIcon}
|
||||||
|
className={hasValue ? '' : 'active'}
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
id='times-circle'
|
||||||
|
icon={CancelIcon}
|
||||||
|
className={hasValue ? 'active' : ''}
|
||||||
|
aria-label={intl.formatMessage(messages.placeholder)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className='search__popout'>
|
||||||
|
{!hasValue && (
|
||||||
|
<>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.recent'
|
||||||
|
defaultMessage='Recent searches'
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className='search__popout__menu'>
|
||||||
|
{recentOptions.length > 0 ? (
|
||||||
|
recentOptions.map(({ label, key, action, forget }, i) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onMouseDown={action}
|
||||||
|
className={classNames(
|
||||||
|
'search__popout__menu__item search__popout__menu__item--flex',
|
||||||
|
{ selected: selectedOption === i },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<button className='icon-button' onMouseDown={forget}>
|
||||||
|
<Icon id='times' icon={CloseIcon} />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className='search__popout__menu__message'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='search.no_recent_searches'
|
||||||
|
defaultMessage='No recent searches'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{quickActions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.quick_actions'
|
||||||
|
defaultMessage='Quick actions'
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className='search__popout__menu'>
|
||||||
|
{quickActions.map(({ key, label, action }, i) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onMouseDown={action}
|
||||||
|
className={classNames('search__popout__menu__item', {
|
||||||
|
selected: selectedOption === i,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h4>
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.options'
|
||||||
|
defaultMessage='Search options'
|
||||||
|
/>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{searchEnabled && signedIn ? (
|
||||||
|
<div className='search__popout__menu'>
|
||||||
|
{searchOptions.map(({ key, label, action }, i) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onMouseDown={action}
|
||||||
|
className={classNames('search__popout__menu__item', {
|
||||||
|
selected:
|
||||||
|
selectedOption ===
|
||||||
|
(quickActions.length || recent.length) + i,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='search__popout__menu__message'>
|
||||||
|
{searchEnabled ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.full_text_search_logged_out_message'
|
||||||
|
defaultMessage='Only available when logged in.'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='search_popout.full_text_search_disabled_message'
|
||||||
|
defaultMessage='Not available on {domain}.'
|
||||||
|
values={{ domain }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,93 +0,0 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
|
||||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
|
||||||
import { expandSearch } from 'mastodon/actions/search';
|
|
||||||
import { Account } from 'mastodon/components/account';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { LoadMore } from 'mastodon/components/load_more';
|
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
||||||
import { SearchSection } from 'mastodon/features/explore/components/search_section';
|
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
|
||||||
|
|
||||||
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
|
||||||
import StatusContainer from '../../../containers/status_container';
|
|
||||||
|
|
||||||
const INITIAL_PAGE_LIMIT = 10;
|
|
||||||
|
|
||||||
const withoutLastResult = list => {
|
|
||||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
|
||||||
return list.skipLast(1);
|
|
||||||
} else {
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SearchResults = () => {
|
|
||||||
const results = useAppSelector((state) => state.getIn(['search', 'results']));
|
|
||||||
const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading']));
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleLoadMoreAccounts = useCallback(() => {
|
|
||||||
dispatch(expandSearch('accounts'));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleLoadMoreStatuses = useCallback(() => {
|
|
||||||
dispatch(expandSearch('statuses'));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleLoadMoreHashtags = useCallback(() => {
|
|
||||||
dispatch(expandSearch('hashtags'));
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
let accounts, statuses, hashtags;
|
|
||||||
|
|
||||||
if (results.get('accounts') && results.get('accounts').size > 0) {
|
|
||||||
accounts = (
|
|
||||||
<SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
|
|
||||||
{withoutLastResult(results.get('accounts')).map(accountId => <Account key={accountId} id={accountId} />)}
|
|
||||||
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />}
|
|
||||||
</SearchSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
|
||||||
hashtags = (
|
|
||||||
<SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
|
|
||||||
{withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
|
||||||
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />}
|
|
||||||
</SearchSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.get('statuses') && results.get('statuses').size > 0) {
|
|
||||||
statuses = (
|
|
||||||
<SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
|
|
||||||
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
|
||||||
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />}
|
|
||||||
</SearchSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='search-results'>
|
|
||||||
{!accounts && !hashtags && !statuses && (
|
|
||||||
isLoading ? (
|
|
||||||
<LoadingIndicator />
|
|
||||||
) : (
|
|
||||||
<div className='empty-column-indicator'>
|
|
||||||
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{accounts}
|
|
||||||
{hashtags}
|
|
||||||
{statuses}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
};
|
|
|
@ -1,59 +0,0 @@
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
changeSearch,
|
|
||||||
clearSearch,
|
|
||||||
submitSearch,
|
|
||||||
showSearch,
|
|
||||||
openURL,
|
|
||||||
clickSearchResult,
|
|
||||||
forgetSearchResult,
|
|
||||||
} from 'mastodon/actions/search';
|
|
||||||
|
|
||||||
import Search from '../components/search';
|
|
||||||
|
|
||||||
const getRecentSearches = createSelector(
|
|
||||||
state => state.getIn(['search', 'recent']),
|
|
||||||
recent => recent.reverse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
value: state.getIn(['search', 'value']),
|
|
||||||
submitted: state.getIn(['search', 'submitted']),
|
|
||||||
recent: getRecentSearches(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onChange (value) {
|
|
||||||
dispatch(changeSearch(value));
|
|
||||||
},
|
|
||||||
|
|
||||||
onClear () {
|
|
||||||
dispatch(clearSearch());
|
|
||||||
},
|
|
||||||
|
|
||||||
onSubmit (type) {
|
|
||||||
dispatch(submitSearch(type));
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow () {
|
|
||||||
dispatch(showSearch());
|
|
||||||
},
|
|
||||||
|
|
||||||
onOpenURL (q, routerHistory) {
|
|
||||||
dispatch(openURL(q, routerHistory));
|
|
||||||
},
|
|
||||||
|
|
||||||
onClickSearchResult (q, type) {
|
|
||||||
dispatch(clickSearchResult(q, type));
|
|
||||||
},
|
|
||||||
|
|
||||||
onForgetSearchResult (q) {
|
|
||||||
dispatch(forgetSearchResult(q));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
|
|
@ -9,8 +9,6 @@ import { Link } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||||
|
@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||||
import { mascot } from '../../initial_state';
|
import { mascot } from '../../initial_state';
|
||||||
import { isMobile } from '../../is_mobile';
|
import { isMobile } from '../../is_mobile';
|
||||||
import Motion from '../ui/util/optional_motion';
|
|
||||||
|
|
||||||
import { SearchResults } from './components/search_results';
|
import { Search } from './components/search';
|
||||||
import ComposeFormContainer from './containers/compose_form_container';
|
import ComposeFormContainer from './containers/compose_form_container';
|
||||||
import SearchContainer from './containers/search_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
|
@ -43,9 +39,8 @@ const messages = defineMessages({
|
||||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state) => ({
|
||||||
columns: state.getIn(['settings', 'columns']),
|
columns: state.getIn(['settings', 'columns']),
|
||||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class Compose extends PureComponent {
|
class Compose extends PureComponent {
|
||||||
|
@ -54,7 +49,6 @@ class Compose extends PureComponent {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
columns: ImmutablePropTypes.list.isRequired,
|
columns: ImmutablePropTypes.list.isRequired,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
showSearch: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -88,7 +82,7 @@ class Compose extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { multiColumn, showSearch, intl } = this.props;
|
const { multiColumn, intl } = this.props;
|
||||||
|
|
||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
const { columns } = this.props;
|
const { columns } = this.props;
|
||||||
|
@ -113,7 +107,7 @@ class Compose extends PureComponent {
|
||||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{multiColumn && <SearchContainer /> }
|
{multiColumn && <Search /> }
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
<div className='drawer__pager'>
|
||||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||||
|
@ -123,14 +117,6 @@ class Compose extends PureComponent {
|
||||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
|
||||||
{({ x }) => (
|
|
||||||
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
|
||||||
<SearchResults />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Motion>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -90,8 +90,8 @@ describe('emoji', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||||
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'))
|
expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>'))
|
||||||
.toEqual('<p><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
.toEqual('<p><picture><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"></picture> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener" target="_blank">#<span>foo</span></a> test: foo.</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
export const SearchSection = ({ title, onClickMore, children }) => (
|
|
||||||
<div className='search-results__section'>
|
|
||||||
<div className='search-results__section__header'>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
SearchSection.propTypes = {
|
|
||||||
title: PropTypes.node.isRequired,
|
|
||||||
onClickMore: PropTypes.func,
|
|
||||||
children: PropTypes.children,
|
|
||||||
};
|
|
|
@ -1,114 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
|
||||||
import Column from 'mastodon/components/column';
|
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
|
||||||
import Search from 'mastodon/features/compose/containers/search_container';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
import { trendsEnabled } from 'mastodon/initial_state';
|
|
||||||
|
|
||||||
import Links from './links';
|
|
||||||
import SearchResults from './results';
|
|
||||||
import Statuses from './statuses';
|
|
||||||
import Suggestions from './suggestions';
|
|
||||||
import Tags from './tags';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
|
||||||
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
layout: state.getIn(['meta', 'layout']),
|
|
||||||
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
class Explore extends PureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
isSearching: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHeaderClick = () => {
|
|
||||||
this.column.scrollTop();
|
|
||||||
};
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.column = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, multiColumn, isSearching } = this.props;
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
|
||||||
<ColumnHeader
|
|
||||||
icon={isSearching ? 'search' : 'explore'}
|
|
||||||
iconComponent={isSearching ? SearchIcon : ExploreIcon}
|
|
||||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='explore__search-header'>
|
|
||||||
<Search />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSearching ? (
|
|
||||||
<SearchResults />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className='account__section-headline'>
|
|
||||||
<NavLink exact to='/explore'>
|
|
||||||
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
<NavLink exact to='/explore/tags'>
|
|
||||||
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
{signedIn && (
|
|
||||||
<NavLink exact to='/explore/suggestions'>
|
|
||||||
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' />
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NavLink exact to='/explore/links'>
|
|
||||||
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch>
|
|
||||||
<Route path='/explore/tags' component={Tags} />
|
|
||||||
<Route path='/explore/links' component={Links} />
|
|
||||||
<Route path='/explore/suggestions' component={Suggestions} />
|
|
||||||
<Route exact path={['/explore', '/explore/posts', '/search']}>
|
|
||||||
<Statuses multiColumn={multiColumn} />
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
|
|
||||||
<Helmet>
|
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
|
|
||||||
</Helmet>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withIdentity(connect(mapStateToProps)(injectIntl(Explore)));
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue