mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-11 11:37:41 -05:00
Merge branch 'main' into improve-devcontainer-config
This commit is contained in:
@@ -56,7 +56,7 @@ services:
|
||||
- internal_network
|
||||
|
||||
es:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ES_JAVA_OPTS: -Xms512m -Xmx512m
|
||||
|
||||
4
.github/workflows/build-container-image.yml
vendored
4
.github/workflows/build-container-image.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- linux/arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Prepare
|
||||
env:
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PUSH_TO_IMAGES: ${{ inputs.push_to_images }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
|
||||
2
.github/workflows/build-push-pr.yml
vendored
2
.github/workflows/build-push-pr.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
# Repository needs to be cloned so `git rev-parse` below works
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- id: version_vars
|
||||
run: |
|
||||
echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short ${{github.event.pull_request.head.sha}}) >> $GITHUB_OUTPUT
|
||||
|
||||
2
.github/workflows/build-releases.yml
vendored
2
.github/workflows/build-releases.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
# Repository needs to be cloned to list branches
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
3
.github/workflows/bundler-audit.yml
vendored
3
.github/workflows/bundler-audit.yml
vendored
@@ -22,13 +22,14 @@ on:
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
|
||||
5
.github/workflows/check-i18n.yml
vendored
5
.github/workflows/check-i18n.yml
vendored
@@ -19,9 +19,10 @@ permissions:
|
||||
jobs:
|
||||
check-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
@@ -38,7 +39,7 @@ jobs:
|
||||
run: bin/i18n-tasks check-normalized
|
||||
|
||||
- name: Check for unused strings
|
||||
run: bin/i18n-tasks unused
|
||||
run: bin/i18n-tasks unused -l en
|
||||
|
||||
- name: Check for missing strings in English YML
|
||||
run: |
|
||||
|
||||
6
.github/workflows/chromatic.yml
vendored
6
.github/workflows/chromatic.yml
vendored
@@ -12,11 +12,12 @@ jobs:
|
||||
pathcheck:
|
||||
name: Check for relevant changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
changed: ${{ steps.filter.outputs.src }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -38,11 +39,12 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: pathcheck
|
||||
if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
3
.github/workflows/codeql.yml
vendored
3
.github/workflows/codeql.yml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -31,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
|
||||
2
.github/workflows/crowdin-download.yml
vendored
2
.github/workflows/crowdin-download.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Increase Git http.postBuffer
|
||||
# This is needed due to a bug in Ubuntu's cURL version?
|
||||
|
||||
2
.github/workflows/crowdin-upload.yml
vendored
2
.github/workflows/crowdin-upload.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
|
||||
|
||||
3
.github/workflows/format-check.yml
vendored
3
.github/workflows/format-check.yml
vendored
@@ -10,10 +10,11 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
3
.github/workflows/lint-css.yml
vendored
3
.github/workflows/lint-css.yml
vendored
@@ -27,10 +27,11 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
3
.github/workflows/lint-haml.yml
vendored
3
.github/workflows/lint-haml.yml
vendored
@@ -25,13 +25,14 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
|
||||
3
.github/workflows/lint-js.yml
vendored
3
.github/workflows/lint-js.yml
vendored
@@ -33,10 +33,11 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
3
.github/workflows/lint-ruby.yml
vendored
3
.github/workflows/lint-ruby.yml
vendored
@@ -27,13 +27,14 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
BUNDLE_ONLY: development
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1
|
||||
|
||||
3
.github/workflows/test-js.yml
vendored
3
.github/workflows/test-js.yml
vendored
@@ -31,10 +31,11 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
3
.github/workflows/test-migrations.yml
vendored
3
.github/workflows/test-migrations.yml
vendored
@@ -25,6 +25,7 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -72,7 +73,7 @@ jobs:
|
||||
BUNDLE_RETRY: 3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
|
||||
14
.github/workflows/test-ruby.yml
vendored
14
.github/workflows/test-ruby.yml
vendored
@@ -19,6 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
@@ -32,7 +33,7 @@ jobs:
|
||||
SECRET_KEY_BASE_DUMMY: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
@@ -75,6 +76,7 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
needs:
|
||||
- build
|
||||
@@ -128,7 +130,7 @@ jobs:
|
||||
- '3.4'
|
||||
- '.ruby-version'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -167,7 +169,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6
|
||||
with:
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
@@ -176,6 +178,7 @@ jobs:
|
||||
test-e2e:
|
||||
name: End to End testing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
needs:
|
||||
- build
|
||||
@@ -222,7 +225,7 @@ jobs:
|
||||
- '.ruby-version'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -279,6 +282,7 @@ jobs:
|
||||
test-search:
|
||||
name: Elastic Search integration testing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
needs:
|
||||
- build
|
||||
@@ -360,7 +364,7 @@ jobs:
|
||||
search-image: opensearchproject/opensearch:2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
|
||||
732
CHANGELOG.md
732
CHANGELOG.md
@@ -2,6 +2,174 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.6.0] - UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- **Add collections** (#37992, #37005, #37049, #37020, #37053, #37110, #37117, #37122, #37154, #37157, #37176, #37192, #37222, #37225, #37254, #37277, #37298, #37322, #37434, #37468, #37514, #37512, #37549, #37556, #37560, #37580, #37591, #37552, #37618, #37643, #37658, #37731, #37678, #37741, #37762, #37790, #37805, #37823, #37837, #37842, #37850, #37848, #37812, #37950, #37898, #37916, #37920, #37927, #37928, #37961, #37967, #37974, #37989, #37986, #38004, #38026, #38027, #38030, #38038, #38065, #38081, #38082, #38096, #38106, #38113, #38124, #38133, #38144, #38153, #38166, #38167, #38169, #38170, #38177, #38193, #38213, #38251, #38255, #38256, #38282, #38298, #38292, #38307, #38306, #38316, #38115, #38329, #38334, #38337, #38351, #38368, #38370, #38356, #38383, #38386, #38385, #38394, #38393, #38399, #38402, #38409, #38414, #38413, #38424, #38425, #38450, #38508, #38528, #38534, #38536, #38540, #38543, #38491, #38586, #38611, #38588, #38612, #38628, #38626, #38630, #38633, #38629, #38638, #38645, #38644, #38636, #38660, #38657, #38688, #38690, #38672, #38698, #38697, #38708, #38712, #38713, #38709, #38719, #38728, #38730, #38732, #38739, #38749, #38751, #38750, #38767, #38769, #38783, #38785, #38959, #38786, #38794, #38776, #38817, #38792, #38822, #38827, #38831, #38830, #38844, #38843, #38852, #38850, #38847, #38865, #38897, #38900, #38919, #38933, #38934, #38935, #38942, #38941, #38954, #38961, #38957, #38962, #38991, #39009, #39062, #39029, #39069, #39020, #39073, #39082, #39096, #39080, #39182, #39143, #39127, #37929, #38029, #39194, #39198, #39210, #39211, #39202, #39214, #39215, #39220, #39234, #39260, #39251, #39361, #39357, #39349, #39287, #39376, #39289, #39342, #38711, #39379, #39282, #39286, #39296, #39047, #39346, #39373, #39372 by @ChaosExAnima, @ClearlyClaire, @Gargron, @arte7, @diondiondion, @mjankowski, @oneiros, and @shleeable)
|
||||
- Create collections with up to 25 accounts each, then share them with others. You can read more about this feature [on our blog](https://blog.joinmastodon.org/2026/04/designing-collections/). This is based on FEP-7aa9 (Featured Collections) to be interoperable with the wider Fediverse. All the new API methods [are documented here](https://docs.joinmastodon.org/client/collections/).
|
||||
- **Add email subscriptions** (#38163, #38507, #38502, #38487, #38527, #38582, #38741, #38907, #39162, #39271 by @ClearlyClaire and @Gargron)
|
||||
- Admins can allow specific roles to enable email subscriptions on their profile, allowing anonymous visitors to subscribe to their posts via email.
|
||||
- **Add new overview landing page setting** (#39074, #39170, #39163, and #39138 by @Gargron, @diondiondion, and @zunda)
|
||||
- Admins can choose a new frontpage for anonymous visitors, which combines the about page and most recent posts from local profiles.
|
||||
- **Add ability to require 2FA for specific roles** (including Everybody) (#37701, #37846, and #38906 by @ClearlyClaire and @mjankowski)
|
||||
- Add import and export for custom filters (#39085, #39256, #39386 by @arte7)
|
||||
- Add ability to search email blocks by domain in admin UI (#38923 by @arte7)
|
||||
- Add new endpoints for profile editing in REST API (#37912, #37934, #37932, #38221, and #38339 by @ClearlyClaire)
|
||||
- Add `GET /api/v1/profile` and `PATCH /api/v1/profile` to replace the existing `update_credentials` endpoint. See [the documentation](https://docs.joinmastodon.org/methods/profile/) for more information.
|
||||
- Add `missing_attribution` boolean to preview cards in REST API (#38043 by @ClearlyClaire)
|
||||
- Documentation: https://docs.joinmastodon.org/entities/PreviewCard/#missing_attribution
|
||||
- Add `exclude_direct` flag to `/api/v1/accounts/:id/statuses` to exclude direct messages (#37763 by @ClearlyClaire)
|
||||
- Add `max_note_length` and `max_display_name_length` attributes to `configuration.accounts` in `Instance` entity (#37991 by @ClearlyClaire)
|
||||
- Add profile field limits to instance entity in REST API (#37535 by @mkljczk)
|
||||
- This adds attributes `configuration.accounts.max_profile_fields`, `configuration.accounts.profile_field_name_limit` and `configuration.accounts.profile_field_value_limit` to the [`Instance` entity](https://docs.joinmastodon.org/entities/Instance).
|
||||
- Add `unresolved` flag to `/api/v1/admin/reports` to query both resolved and unresolved reports (#38323 by @mkljczk)
|
||||
- Add fallback attributes to notifications for new and infrequent notifications in REST API (#38832 and #38860 by @ClearlyClaire)
|
||||
- This adds a [`supported_types`](https://docs.joinmastodon.org/methods/notifications/#query-parameters-1) parameter to `GET /api/v1/notifications`, `GET /api/v1/notifications/:id`, `GET /api/v2/notifications`, and `GET /api/v2/notifications/:group_key` along with a new `fallback` attribute for notifications and notification groups.
|
||||
- Add support for posts in vertical languages in web UI (#37204, #38205, and #38797 by @shimon1024)
|
||||
- Add `PageUp` and `PageDown` hotkeys for list navigation (#39252 by @diondiondion)
|
||||
- Add `g`+`e` keyboard shortcut to access the trending page in web UI (#38014 by @antoinecellerier)
|
||||
- Add `Cmd`/`Ctrl`+`Enter` for form submissions in more text areas in web UI (#37821 by @diondiondion)
|
||||
- Add support for quoting by dragging a link into the compose form in web UI (#36859 and #36896 by @ClearlyClaire and @tribela)
|
||||
- Add `text-autospace` to posts to improve rendering of mixed script posts in web UI (#37694 by @ahxxm)
|
||||
- Add Taiwanese (Minnan), Lazuri, Mingrelian and Ottoman Turkish to supported locales (#37650, #34923, #37822, #37721, #38648 by @ClearlyClaire and @Yoxem)
|
||||
- Add ability to filter notifications from bots (#38809 and #39377 by @evanp and @shleeable)
|
||||
- Add support for `hosts` resolver in request socket DNS lookup (#38699, #38866, and #39030 by @ClearlyClaire and @mjankowski)
|
||||
- Add support for FEP-2c59 (Webfinger Backlink) (#38239, #38538, and #38639 by @ClearlyClaire and @shleeable)
|
||||
- Add support for FEP-3b86 (Activity Intents) (#38120 and #38130 by @ClearlyClaire and @Gargron)
|
||||
- Add support for alt text for profile pictures and headers (#37634, #37641, #38000, #39352 by @ClearlyClaire and @Doxterpepper)
|
||||
- Add support for multiple keypairs for remote accounts (#38279, #38407, #38419, #38511, #38516, #38515, #38555 and #39235 by @ClearlyClaire)
|
||||
- Add duration to ActivityPub representation of media attachments (#38061 by @ClearlyClaire)
|
||||
- Add Stoplight circuit-breaker on Elasticsearch endpoints to better handle some Elasticsearch failures (#39323 and #39375 by @ClearlyClaire and @shleeable)
|
||||
- Add support for the “require approval” feature for email domain blocks to `tootctl email_domain_blocks` (#34579 and #38107 by @ClearlyClaire and @e-nomem)
|
||||
- Add `--keep-interacted` flag to `tootctl media remove` to preserve cached media on cleanup (#36200 by @northerner)
|
||||
- Add systemd service file for prometheus exporter (#35130 by @ThisIsMissEm)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change design of profiles in web UI** (#37472, #37490, #37479, #37513, #37527, #37550, #37538, #37632, #37627, #37593, #37638, #37626, #37645, #37653, #37683, #37707, #37682, #37742, #37747, #37760, #37761, #37831, #37766, #37811, #37813, #37825, #37854, #37851, #37876, #37885, #37892, #37890, #37907, #37922, #37952, #37958, #37996, #37990, #37994, #38005, #38012, #38040, #38052, #38066, #38083, #38147, #38148, #38152, #38168, #38156, #38175, #38191, #38189, #38235, #38283, #38310, #38309, #38315, #38314, #38365, #38366, #38363, #38346, #38382, #38384, #38400, #38404, #38417, #38426, #38440, #38442, #38443, #38445, #38446, #38451, #38456, #38509, #38510, #38512, #38513, #38517, #38529, #38531, #38535, #38532, #38544, #38549, #38575, #38579, #38580, #38581, #38585, #38584, #38604, #38605, #38606, #38607, #38622, #38616, #38625, #38632, #38640, #38663, #38667, #38646, #38691, #38692, #38766, #38791, #38687, #38826, #38828, #38863, #38845, #38870, #38872, #38932, #38945, #38963, #38964, #39055, #39042, #38893, #39079, #39084, #39160, #39070, #39217, #39309, #39354, #39324, #39387 by @ChaosExAnima, @ClearlyClaire, @Coro365, @diondiondion, and @shleeable)
|
||||
- The profile screen has been entirely redesigned, has new features, and allows you to update your own profile directly without going into the preferences panel. You can read more about it [on our blog](https://blog.joinmastodon.org/2026/03/a-redesign-for-profiles/).
|
||||
- **Change how #Wrapstodon reports are generated and displayed** (#37033, #37045, #37093, #37055, #37096, #37047, #37103, #37104, #37106, #37109, #37121, #37138, #37134, #37177, #37182, #37169, #37186, #37187, #37188, #37189, #37190, #37193, #37198, #37201, #37203, #37205, #37206, #37207, #37209, #37202, #37216, #37219, #37224, #37226, #37229, #37249, #37251, #37256, #37261, #37269, #37270, #37273, and #37289 by @ChaosExAnima, @ClearlyClaire, @channyeintun, and @diondiondion)
|
||||
- This finishes up work started in 2024 by completely revamping how Wrapstodon reports are generated and displayed, reducing the amount of data collected and generating reports when active users ask for them.
|
||||
- Instead of requiring manual generation from a server administrator, this is now offered between the 10th of December and the end of each year if enabled in the server settings.
|
||||
- The design of the Wrapstodon report has also been fully reworked to be more delightful and easier to share!
|
||||
- The relevant API endpoints are documented at https://docs.joinmastodon.org/methods/annual_reports/
|
||||
- Change limitation to allow posts with both media and a poll to be created (#39203, #39368, #39388 by @ClearlyClaire and @Gargron)
|
||||
- Change alt text limit for media attachments to 10,000 characters (#39306 by @ClearlyClaire)
|
||||
- Change pending user notification email to link directly to the pending account (#39206 by @vmstan)
|
||||
- Changed emoji processing in web UI to make it less resource intensive and more robust (#39077, #39008, #39088, #38892, #38885, #38965, #38854, #38825, #38784, #38541, #37442, #37300, #37306, #37271, #37255, #37284, #37272, #37178, #37084, #37080, #37418, #39167, #39126, #39353, #39378, #39382 by @ChaosExAnima, @ClearlyClaire, @diondiondion, and @gomasy)
|
||||
- Change composer textarea to have a limited height to prevent column scrolling (#39268 by @diondiondion)
|
||||
- Change mentions of “Mastodon gGmbH” to “Mastodon GmbH” (#39261 by @renchap)
|
||||
- Change the limited profile message to be less misleading (#39231 by @mortie)
|
||||
- Change images/videos in posts in web UI to not have unlimited height (#36966, #37035, #37136, and #37032 by @diondiondion)
|
||||
- Change search field and tabs to stick to the top on the search results page in web UI (#38968 by @diondiondion)
|
||||
- Change “anyone can quote” label to “quotes allowed” in web UI (#37427 by @vmstan)
|
||||
- Change navigation by `j`/`k` hotkeys to anchor navigated item to top of viewport in web UI (#38036 by @diondiondion)
|
||||
- Change hotkeys to focus columns to not reset scroll, add hotkey `0` to scroll to top in web UI (#37052 by @diondiondion)
|
||||
- Change media modal swipe animation in web UI (#36916, #37034, #37323, and #37464 by @ChaosExAnima and @heathdutton)
|
||||
- Change “Hide”/“Show all” eye icon in thread view in web UI (#22301 by @tribela)
|
||||
- Change order of onboarding steps (follow people, then fill out profile) in web UI (#38121 by @Gargron)
|
||||
- Change “Why do you want to join” field on the sign-up page to have a label (#38936 by @diondiondion)
|
||||
- Change date of birth field on the sign-up page to use locale-specific fields order (#36039 and #36895 by @mjankowski)
|
||||
- Change how invalid-but-not-expired invites are shown in admin UI (#38736 by @ClearlyClaire)
|
||||
- Change wording and ordering of media display settings (#38731 by @mjankowski)
|
||||
- Change wording of server account recommendation setting description (#36771 by @mjankowski)
|
||||
- Change wording and ordering of account migration warnings (#20387 by @jsoref)
|
||||
- Change wording of “Automatic post deletion” settings (#37286 by @mjankowski)
|
||||
- Change wording of language filter settings to clarify they do not impact home/lists (#38490 by @mjankowski)
|
||||
- Change wording of `tootctl preview_cards remove` command description to clearly state it only removes media (#39348 by @mjankowski)
|
||||
- Change invitations to only bypass sign-up approval setting when the issuer of the invitation has the `invite_bypass_approval` permission (#38278 by @ClearlyClaire)
|
||||
- This splits the “Invite Users” permission into a new “Invite Users without review” permission.
|
||||
- Existing roles will be updated to have the new permission if they have the old one, but default permissions will not include the new `invite_bypass_approval` permission.
|
||||
- Change followers synchronization mechanism on followers-only posts to be skipped for accounts with 25k followers or more (#37302 by @ClearlyClaire)
|
||||
- Change "Accept" link on sign-up page to a form to prevent some crawling behavior (#39283 and #39345 by @ClearlyClaire and @mjankowski)
|
||||
- Change “dark”, “light” and “high contrast” themes to be separate “Color scheme” and “Contrast” settings handled by a single theme (#37095, #37120, #37288, #37459, #37470, #37477, #37519, #37520, #37523, #37524, #37526, #37612, #37824, #37807, #37810, #37819, #37906, and #38261 by @ClearlyClaire, @diondiondion, and @mjankowski)
|
||||
- Existing settings should be migrated automatically from user settings, and using browser defaults otherwise.
|
||||
- This also allows third-party theme authors to make use of the same browser defaults and user settings. Learn more about this in [our new Theming docs](https://docs.joinmastodon.org/dev/frontend/theming/).
|
||||
- Change default theme to use CSS theme tokens (#36861, #36936, #37019, #37054, #37056, #37081, #37105, #37268, #37841, #37843, #38387, #38459, and #38621 by @diondiondion)
|
||||
- A [guide to using the new tokens](https://docs.joinmastodon.org/dev/frontend/design-tokens/) can be found in our docs.
|
||||
- Change location blocks in default `nginx.conf` (#19644 and #37866 by @BedrockDigger and @Izorkin)
|
||||
- Change `proxy_read_timeout` to 120 seconds in default `nginx.conf` (#30599 by @shleeable)
|
||||
- Change JSON-LD collection handling (#34595 and #37806 by @ClearlyClaire and @sneakers-the-rat)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove support for EOL Node version 20 (#38926 by @mjankowski)
|
||||
- Remove support for Ruby 3.2 (#37476 by @mjankowski)
|
||||
- Remove support for `ENABLE_SIDEKIQ_UNIQUE_JOBS_UI` (#38340 by @ClearlyClaire)
|
||||
- Remove support for ImageMagick (#37488 by @mjankowski)
|
||||
- Remove outdated hint for "Use system scrollbar" preference (#39297 by @diondiondion)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix accessibility issues in web UI (#37250, #38006, #38033, #38188, #38230, #38252, #38257, #38285, #38293, #38362, #38387, #38459, #38796, #38801, #39098, #39111, #39120, #39129, #39133, #39134, #39144, #39145, #39149, #39164, #39165, #39169, #39181, #39335, #39305, #39331, #39356, #39350, #39358, #39360, #39325, #39270 by @ChaosExAnima and @diondiondion)
|
||||
- Fix tiny checkboxes and radio buttons in Safari (#39332 by @diondiondion)
|
||||
- Fix handling of offset in timezone list in settings (#39334 by @mjankowski)
|
||||
- Fix being unable to unmark media as sensitive when "always mark media as sensitive" is enabled in web UI (#39339 by @matrix07012)
|
||||
- Fix display of sensitive media cards in web UI according to settings (#39366 by @nshki)
|
||||
- Fix some inputs incorrectly having resize handles in Firefox (#39274 by @diondiondion)
|
||||
- Fix processing some link previews where text is language-tagged (#39190 by @zunda)
|
||||
- Fix error when “New trends” email is sent at the same time trends are recomputed (#39122 by @arte7)
|
||||
- Fix hover card opening even when not preceded by mouse movement in web UI (#39166, #39381 by @diondiondion)
|
||||
- Fix [ominous](https://mastodon.social/@mcc/116404362104299129) "Moments remaining" timestamp in web UI (#38488 and #38689 by @ChaosExAnima and @MitarashiDango)
|
||||
- Fix filters not being applied to search results in web UI (#36346 by @ClearlyClaire)
|
||||
- Fix error when visiting non-public hashtag timelines (#36961 by @diondiondion)
|
||||
- Fix duplicate favourite/boost counters in some languages (#36844 by @ChaosExAnima)
|
||||
- Fix unblocking domain from blocked domains column not updating the list in web UI (#38882 by @tribela)
|
||||
- Fix "change thumbnail" button being visible when it shouldn't in web UI (#38467 by @dpbento)
|
||||
- Fix profile dropdown menu sometimes ending with a separator in web UI (#38481 by @mkljczk)
|
||||
- Fix short numbers rounding up instead of truncating in web UI (#38114 by @serranodfm)
|
||||
- Fix directory showing load more button when no more profiles exist in web UI (#37465 by @heathdutton)
|
||||
- Fix focus restoration after closing some modals in web UI (#37424 by @MegaManSec)
|
||||
- Fix video modals being pushed down on mobile in web UI (#37421 by @ChaosExAnima)
|
||||
- Fix outer page margins when viewport width equals content width in web UI (#36733 by @diondiondion)
|
||||
- Fix announcement margin when in advanced web UI (#36714 by @ChaosExAnima)
|
||||
- Fix navigation overflow issue in advanced web UI (#39178 by @diondiondion)
|
||||
- Fix stale merging stale account from cached instance API response in web UI (#37666 by @ChaosExAnima)
|
||||
- Fix HTML `lang` attribute being stripped out of remote posts (#39114 by @artemist)
|
||||
- Fix remote posts with large media descriptions being rejected (#39135 by @ClearlyClaire)
|
||||
- Fix some occurrence of PostgreSQL log pollution when processing new hashtags (#35792 by @oelison)
|
||||
- Fix blocked domains not being removed from the Instance search index (#39109 by @shleeable)
|
||||
- Fix Elasticsearch connections not being cleaned up properly in Sidekiq middleware (#39359 by @ClearlyClaire)
|
||||
- Fix replica database not being used when `REPLICA_DB_HOST` is used but neither `REPLICA_DB_NAME` nor `REPLICA_DATABASE_URL` (#37240 by @smiba)
|
||||
- Fix remote media attachment thumbnails not being stored in the `cache/` directory (#36911 by @shugo)
|
||||
- Fix race condition when processing posts twice with the same idempotency key (#37879 by @ClearlyClaire)
|
||||
- Fix `expire_at` instead of `expires_at` in muted words CSV exports (#39304 by @arte7)
|
||||
- Fix various missing translation strings (#37671, #37838, #37078, #37371, #37827, #39328 by @ClearlyClaire, @mjankowski, and @valtlai)
|
||||
- Fix last post time for remote accounts not being accurately tracked (#37619 by @ClearlyClaire)
|
||||
- Fix filtering of mentions from filtered-on-their-origin-server accounts (#37583 by @ClearlyClaire)
|
||||
- Fix irrelevant remote accounts being passed through to local fan-out worker (#37589 by @ClearlyClaire)
|
||||
- Fix required field markers being displayed on fields that cannot be empty anyway in settings (#37291 by @diondiondion)
|
||||
- Fix thumbnails for links from The Guardian (and possibly other CDNs that check URL hashes) not showing up (#36139 by @phocks)
|
||||
- Fix `mastodon-async-refresh` response header not being exposed through CORS (#38914 by @mkljczk)
|
||||
- Fix FASP availability being incorrectly updated (#38818 by @oneiros)
|
||||
- Fix use of deprecated `vsync` FFmpeg option, using `fps_mode` instead (FFmpeg >= 5.1 now required) (#38198 by @mjankowski)
|
||||
- Fix unnecessary downcasing of some words in admin UI (#37364 by @ClearlyClaire)
|
||||
- Fix delivery worker counting unsalvageable HTTP errors as successes (#37235 by @shleeable)
|
||||
- Fix streaming heartbeat comment not being its own event (#37389 by @ClearlyClaire)
|
||||
- Fix posts with edited out media attachments being returned in `GET /api/v1/accounts/:id/statuses?only_media=true` (#37363 by @ClearlyClaire)
|
||||
- Fix wrong media attachment URLs being returned from `DELETE /api/v1/statuses/:id` (#35880 by @dbarabashh)
|
||||
- Fix hashtag matching by replacing negative look-behind with positive look-behind (#37684 and #38212 by @ClearlyClaire)
|
||||
- Fix discovery of ActivityPub representation from HTML tags in presence of a non-ActivityPub alternate Link header (#37439 by @shleeable)
|
||||
- Fix Webfinger endpoint not handling new ActivityPub ID scheme (#38391 by @ClearlyClaire)
|
||||
- Fix error when admin-selected theme does not exist by falling back to `default` theme (#38703 by @shleeable)
|
||||
- Fix wrong endonyms for Divehi and Latvian in languages list (#36254 and #36876 by @cuu508 and @shimon1024)
|
||||
- Fix `Accept` headers when fetching ActivityPub resources not including JSON-LD profile (#30354 by @TheOneric)
|
||||
- Fix wrong hover indicators on unclickable items in admin UI (#38782 by @diondiondion)
|
||||
- Fix streaming server using deprecated `url.parse` instead of WHATWG URL API (#36973 by @Exagone313)
|
||||
|
||||
## [4.5.11] - 2026-06-03
|
||||
|
||||
### Security
|
||||
|
||||
- Fix allowed attribution domains spoofing ([GHSA-rwcw-vq68-g34p](https://github.com/mastodon/mastodon/security/advisories/GHSA-rwcw-vq68-g34p))
|
||||
- Fix uncaught exception in message sanitization causing Denial of Service ([GHSA-qrgq-9fx2-vf2r](https://github.com/mastodon/mastodon/security/advisories/GHSA-qrgq-9fx2-vf2r))
|
||||
- Update dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix remote statuses with large media descriptions being rejected (#39135 by @ClearlyClaire)
|
||||
|
||||
## [4.5.10] - 2026-05-20
|
||||
|
||||
### Security
|
||||
@@ -746,566 +914,4 @@ All notable changes to this project will be documented in this file.
|
||||
- Fix use of deprecated `execCommand` for copying text by using the `clipboard` API (#32598 by @renchap)
|
||||
- Fix some translation strings not being properly pluralized (#27094 by @gunchleoc)
|
||||
|
||||
## [4.3.8] - 2025-05-06
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
|
||||
|
||||
### Added
|
||||
|
||||
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
|
||||
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
|
||||
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
|
||||
|
||||
## [4.3.7] - 2025-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
|
||||
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire)
|
||||
- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski)
|
||||
- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire)
|
||||
- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire)
|
||||
- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire)
|
||||
- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire)
|
||||
- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire)
|
||||
- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire)
|
||||
- Fix “x” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire)
|
||||
|
||||
## [4.3.6] - 2025-03-13
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependency `omniauth-saml`
|
||||
- Update dependency `rack`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire)
|
||||
|
||||
## [4.3.5] - 2025-03-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap)
|
||||
- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire)
|
||||
- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire)
|
||||
- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire)
|
||||
|
||||
## [4.3.4] - 2025-02-27
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf))
|
||||
- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h))
|
||||
- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825))
|
||||
|
||||
### Changed
|
||||
|
||||
- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire)
|
||||
- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire)
|
||||
- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski)
|
||||
- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire)
|
||||
- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke)
|
||||
- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire)
|
||||
- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire)
|
||||
- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire)
|
||||
- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire)
|
||||
- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire)
|
||||
- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire)
|
||||
- Fix polls not being validated on edition (#33755 by @ClearlyClaire)
|
||||
- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire)
|
||||
- Fix preview card sizing in “Author attribution” in profile settings (#33482 by @ClearlyClaire)
|
||||
- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire)
|
||||
- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski)
|
||||
- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron)
|
||||
- Fix accounts table long display name (#29316 by @WebCoder49)
|
||||
- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan)
|
||||
|
||||
## [4.3.3] - 2025-01-16
|
||||
|
||||
### Security
|
||||
|
||||
- Fix insufficient validation of account URIs ([GHSA-5wxh-3p65-r4g6](https://github.com/mastodon/mastodon/security/advisories/GHSA-5wxh-3p65-r4g6))
|
||||
- Update dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `libyaml` missing from `Dockerfile` build stage (#33591 by @vmstan)
|
||||
- Fix incorrect notification settings migration for non-followers (#33348 by @ClearlyClaire)
|
||||
- Fix down clause for notification policy v2 migrations (#33340 by @jesseplusplus)
|
||||
- Fix error decrementing status count when `FeaturedTags#last_status_at` is `nil` (#33320 by @ClearlyClaire)
|
||||
- Fix last paginated notification group only including data on a single notification (#33271 by @ClearlyClaire)
|
||||
- Fix processing of mentions for post edits with an existing corresponding silent mention (#33227 by @ClearlyClaire)
|
||||
- Fix deletion of unconfirmed users with Webauthn set (#33186 by @ClearlyClaire)
|
||||
- Fix empty authors preview card serialization (#33151, #33466 by @mjankowski and @ClearlyClaire)
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- Add more explicit explanations about author attribution and `fediverse:creator` (#32383 by @ClearlyClaire)
|
||||
- Add ability to group follow notifications in WebUI, can be disabled in the column settings (#32520 by @renchap)
|
||||
- Add back a 6 hours mute duration option (#32522 by @renchap)
|
||||
- Add note about not changing ActiveRecord encryption secrets once they are set (#32413, #32476, #32512, and #32537 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change translation feature to translate to selected regional variant (e.g. pt-BR) if available (#32428 by @c960657)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove ability to get embed code for remote posts (#32578 by @ClearlyClaire)\
|
||||
Getting the embed code is only reliable for local posts.\
|
||||
It never worked for non-Mastodon servers, and stopped working correctly with the changes made in 4.3.0.\
|
||||
We have therefore decided to remove the menu entry while we investigate solutions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix follow recommendation moderation page default language when using regional variant (#32580 by @ClearlyClaire)
|
||||
- Fix column-settings spacing in local timeline in advanced view (#32567 by @lindwurm)
|
||||
- Fix broken i18n in text welcome mailer tags area (#32571 by @mjankowski)
|
||||
- Fix missing or incorrect cache-control headers for Streaming server (#32551 by @ThisIsMissEm)
|
||||
- Fix only the first paragraph being displayed in some notifications (#32348 by @ClearlyClaire)
|
||||
- Fix reblog icons on account media view (#32506 by @tribela)
|
||||
- Fix Content-Security-Policy not allowing OpenStack SWIFT object storage URI (#32439 by @kenkiku1021)
|
||||
- Fix back arrow pointing to the incorrect direction in RTL languages (#32485 by @renchap)
|
||||
- Fix streaming server using `REDIS_USERNAME` instead of `REDIS_USER` (#32493 by @ThisIsMissEm)
|
||||
- Fix follow recommendation carrousel scrolling on RTL layouts (#32462 and #32505 by @ClearlyClaire)
|
||||
- Fix follow recommendation suppressions not applying immediately (#32392 by @ClearlyClaire)
|
||||
- Fix language of push notifications (#32415 by @ClearlyClaire)
|
||||
- Fix mute duration not being shown in list of muted accounts in web UI (#32388 by @ClearlyClaire)
|
||||
- Fix “Mark every notification as read” not updating the read marker if scrolled down (#32385 by @ClearlyClaire)
|
||||
- Fix “Mention” appearing for otherwise filtered posts (#32356 by @ClearlyClaire)
|
||||
- Fix notification requests from suspended accounts still being listed (#32354 by @ClearlyClaire)
|
||||
- Fix list edition modal styling (#32358 and #32367 by @ClearlyClaire and @vmstan)
|
||||
- Fix 4 columns barely not fitting on 1920px screen (#32361 by @ClearlyClaire)
|
||||
- Fix icon alignment in applications list (#32293 by @mjankowski)
|
||||
|
||||
## [4.3.0] - 2024-10-08
|
||||
|
||||
The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by @mjankowski.
|
||||
|
||||
### Security
|
||||
|
||||
- **Add confirmation interstitial instead of silently redirecting logged-out visitors to remote resources** (#27792, #28902, and #30651 by @ClearlyClaire and @Gargron)\
|
||||
This fixes a longstanding open redirect in Mastodon, at the cost of added friction when local links to remote resources are shared.
|
||||
- Fix ReDoS vulnerability on some Ruby versions ([GHSA-jpxp-r43f-rhvx](https://github.com/mastodon/mastodon/security/advisories/GHSA-jpxp-r43f-rhvx))
|
||||
- Change `form-action` Content-Security-Policy directive to be more restrictive (#26897 and #32241 by @ClearlyClaire)
|
||||
- Update dependencies
|
||||
|
||||
### Added
|
||||
|
||||
- **Add server-side notification grouping** (#29889, #30576, #30685, #30688, #30707, #30776, #30779, #30781, #30440, #31062, #31098, #31076, #31111, #31123, #31223, #31214, #31224, #31299, #31325, #31347, #31304, #31326, #31384, #31403, #31433, #31509, #31486, #31513, #31592, #31594, #31638, #31746, #31652, #31709, #31725, #31745, #31613, #31657, #31840, #31610, #31929, #32089, #32085, #32243, #32179 and #32254 by @ClearlyClaire, @Gargron, @mgmn, and @renchap)\
|
||||
Group notifications of the same type for the same target, so that your notifications no longer get cluttered by boost and favorite notifications as soon as a couple of your posts get traction.\
|
||||
This is done server-side so that clients can efficiently get relevant groups without having to go through numerous pages of individual notifications.\
|
||||
As part of this, the visual design of the entire notifications feature has been revamped.\
|
||||
This feature is intended to eventually replace the existing notifications column, but for this first beta, users will have to enable it in the “Experimental features” section of the notifications column settings.\
|
||||
The API is not final yet, but it consists of:
|
||||
- a new `group_key` attribute to `Notification` entities
|
||||
- `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped
|
||||
- `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group
|
||||
- `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts
|
||||
- `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group
|
||||
- `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count
|
||||
- **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\
|
||||
The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\
|
||||
You can now separately filter or drop notifications from people you don't follow, people who don't follow you, accounts created within the past 30 days, as well as unsolicited private mentions, and accounts limited by the moderation.\
|
||||
Instead of being outright dropped, notifications that you chose to filter are put in a separate “Filtered notifications” box that you can review separately without it clogging your main notifications.\
|
||||
This adds the following REST API endpoints:
|
||||
- `GET /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#get-policy
|
||||
- `PATCH /api/v2/notifications/policy`: https://docs.joinmastodon.org/methods/notifications/#update-the-filtering-policy-for-notifications
|
||||
- `GET /api/v1/notifications/requests`: https://docs.joinmastodon.org/methods/notifications/#get-requests
|
||||
- `GET /api/v1/notifications/requests/:id`: https://docs.joinmastodon.org/methods/notifications/#get-one-request
|
||||
- `POST /api/v1/notifications/requests/:id/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-request
|
||||
- `POST /api/v1/notifications/requests/:id/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-request
|
||||
- `POST /api/v1/notifications/requests/accept`: https://docs.joinmastodon.org/methods/notifications/#accept-multiple-requests
|
||||
- `POST /api/v1/notifications/requests/dismiss`: https://docs.joinmastodon.org/methods/notifications/#dismiss-multiple-requests
|
||||
- `GET /api/v1/notifications/requests/merged`: https://docs.joinmastodon.org/methods/notifications/#requests-merged
|
||||
|
||||
In addition, accepting one or more notification requests generates a new streaming event:
|
||||
- `notifications_merged`: an event of this type indicates accepted notification requests have finished merging, and the notifications list should be refreshed
|
||||
|
||||
- **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.\
|
||||
Note that this does not notify remote users.\
|
||||
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)\
|
||||
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.
|
||||
- **Add "system" theme setting (light/dark theme depending on user system preference)** (#29748, #29553, #29795, #29918, #30839, and #30861 by @nshki, @ErikUden, @mjankowski, @renchap, and @vmstan)\
|
||||
Add a “system” theme that automatically switch between default dark and light themes depending on the user's system preferences.\
|
||||
Also changes the default server theme to this new “system” theme so that automatic theme selection happens even when logged out.
|
||||
- **Add timeline of public posts about a trending link** (#30381 and #30840 by @Gargron)\
|
||||
You can now see public posts mentioning currently-trending articles from people who have opted into discovery features.\
|
||||
This adds a new REST API endpoint: https://docs.joinmastodon.org/methods/timelines/#link
|
||||
- **Add author highlight for news articles whose authors are on the fediverse** (#30398, #30670, #30521, #30846, #31819, #31900 and #32188 by @Gargron, @mjankowski and @oneiros)\
|
||||
This adds a mechanism to [highlight the author of news articles](https://blog.joinmastodon.org/2024/07/highlighting-journalism-on-mastodon/) shared on Mastodon.\
|
||||
Articles hosted outside the fediverse can indicate a fediverse author with a meta tag:
|
||||
```html
|
||||
<meta name="fediverse:creator" content="username@domain" />
|
||||
```
|
||||
On the API side, this is represented by a new `authors` attribute to the `PreviewCard` entity: https://docs.joinmastodon.org/entities/PreviewCard/#authors \
|
||||
Users can allow arbitrary domains to use `fediverse:creator` to credit them by visiting `/settings/verification`.\
|
||||
This is federated as a new `attributionDomains` property in the `http://joinmastodon.org/ns` namespace, containing an array of domain names: https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
|
||||
- **Add in-app notifications for moderation actions and warnings** (#30065, #30082, and #30081 by @ClearlyClaire)\
|
||||
In addition to email notifications, also notify users of moderation actions or warnings against them directly within the app, so they are less likely to miss important communication from their moderators.\
|
||||
This adds the `moderation_warning` notification type to the REST API and streaming, with a new [`moderation_warning` attribute](https://docs.joinmastodon.org/entities/Notification/#moderation_warning).
|
||||
- **Add domain information to profiles in web UI** (#29602 by @Gargron)\
|
||||
Clicking the domain of a user in their profile will now open a tooltip with a short explanation about servers and federation.
|
||||
- **Add support for Redis sentinel** (#31694, #31623, #31744, #31767, and #31768 by @ThisIsMissEm and @oneiros)\
|
||||
See https://docs.joinmastodon.org/admin/scaling/#redis-sentinel
|
||||
- **Add ability to reorder uploaded media before posting in web UI** (#28456 and #32093 by @Gargron)
|
||||
- Add “A Mastodon update is available.” message on admin dashboard for non-bugfix updates (#32106 by @ClearlyClaire)
|
||||
- Add ability to view alt text by clicking the ALT badge in web UI (#32058 by @Gargron)
|
||||
- Add preview of followers removed in domain block modal in web UI (#32032 and #32105 by @ClearlyClaire and @Gargron)
|
||||
- Add reblogs and favourites counts to statuses in ActivityPub (#32007 by @Gargron)
|
||||
- Add moderation interface for searching hashtags (#30880 by @ThisIsMissEm)
|
||||
- Add ability for admins to configure instance favicon and logo (#30040, #30208, #30259, #30375, #30734, #31016, and #30205 by @ClearlyClaire, @FawazFarid, @JasonPunyon, @mgmn, and @renchap)\
|
||||
This is also exposed through the REST API: https://docs.joinmastodon.org/entities/Instance/#icon
|
||||
- Add `api_versions` to `/api/v2/instance` (#31354 by @ClearlyClaire)\
|
||||
Add API version number to make it easier for clients to detect compatible features going forward.\
|
||||
See API documentation at https://docs.joinmastodon.org/entities/Instance/#api-versions
|
||||
- Add quick links to Administration and Moderation Reports from Web UI (#24838 by @ThisIsMissEm)
|
||||
- Add link to `/admin/roles` in moderation interface when changing someone's role (#31791 by @ClearlyClaire)
|
||||
- Add recent audit log entries in federation moderation interface (#27386 by @ThisIsMissEm)
|
||||
- Add profile setup to onboarding in web UI (#27829, #27876, and #28453 by @Gargron)
|
||||
- Add prominent share/copy button on profiles in web UI (#27865 and #27889 by @ClearlyClaire and @Gargron)
|
||||
- Add optional hints for server rules (#29539 and #29758 by @ClearlyClaire and @Gargron)\
|
||||
Server rules can now be broken into a short rule name and a longer explanation of the rule.\
|
||||
This adds a new [`hint` attribute](https://docs.joinmastodon.org/entities/Rule/#hint) to `Rule` entities in the REST API.
|
||||
- Add support for PKCE in OAuth flow (#31129 by @ThisIsMissEm)
|
||||
- Add CDN cache busting on media deletion (#31353 and #31414 by @ClearlyClaire and @tribela)
|
||||
- Add the OAuth application used in local reports (#30539 by @ThisIsMissEm)
|
||||
- Add hint to user that other remote statuses may be missing (#26910, #31387, and #31516 by @Gargron, @audiodude, and @renchap)
|
||||
- Add lang attribute on preview card title (#31303 by @c960657)
|
||||
- Add check for `Content-Length` in `ResponseWithLimitAdapter` (#31285 by @c960657)
|
||||
- Add `Accept-Language` header to fetch preview cards in the server's default language (#31232 by @c960657)
|
||||
- Add support for PKCE Extension in OmniAuth OIDC through the `OIDC_USE_PKCE` environment variable (#31131 by @ThisIsMissEm)
|
||||
- Add API endpoints for unread notifications count (#31191 by @ClearlyClaire)\
|
||||
This adds the following REST API endpoints:
|
||||
- `GET /api/v1/notifications/unread_count`: https://docs.joinmastodon.org/methods/notifications/#unread-count
|
||||
- Add `/` keyboard shortcut to focus the search field (#29921 by @ClearlyClaire)
|
||||
- Add button to view the Hashtag on the instance from Hashtags in Moderation UI (#31533 by @ThisIsMissEm)
|
||||
- Add list of pending releases directly in mail notifications for version updates (#29436 and #30035 by @ClearlyClaire)
|
||||
- Add “Appeals” link under “Moderation” navigation category in moderation interface (#31071 by @ThisIsMissEm)
|
||||
- Add badge on account card in report moderation interface when account is already suspended (#29592 by @ClearlyClaire)
|
||||
- Add admin comments directly to the `admin/instances` page (#29240 by @tribela)
|
||||
- Add ability to require approval when users sign up using specific email domains (#28468, #28732, #28607, and #28608 by @ClearlyClaire)
|
||||
- Add banner for forwarded reports made by remote users about remote content (#27549 by @ClearlyClaire)
|
||||
- Add support HTML ruby tags in remote posts for east-asian languages (#30897 by @ThisIsMissEm)
|
||||
- Add link to manage warning presets in admin navigation (#26199 by @vmstan)
|
||||
- Add volume saving/reuse to video player (#27488 by @thehydrogen)
|
||||
- Add Elasticsearch index size, ffmpeg and ImageMagick versions to the admin dashboard (#27301, #30710, #31130, and #30845 by @vmstan)
|
||||
- Add `MASTODON_SIDEKIQ_READY_FILENAME` environment variable to use a file for Sidekiq to signal it is ready to process jobs (#30971 and #30988 by @renchap)\
|
||||
In the official Docker image, this is set to `sidekiq_process_has_started_and_will_begin_processing_jobs` so that Sidekiq will touch `tmp/sidekiq_process_has_started_and_will_begin_processing_jobs` to signal readiness.
|
||||
- Add `S3_RETRY_LIMIT` environment variable to make S3 retries configurable (#23215 by @smiba)
|
||||
- Add `S3_KEY_PREFIX` environment variable (#30181 by @S0yKaf)
|
||||
- Add support for multiple `redirect_uris` when creating OAuth 2.0 Applications (#29192 by @ThisIsMissEm)
|
||||
- Add Interlingue and Interlingua to interface languages (#28630 and #30828 by @Dhghomon and @renchap)
|
||||
- Add Kashubian, Pennsylvania Dutch, Vai, Jawi Malay, Mohawk and Low German to posting languages (#26024, #26634, #27136, #29098, #27115, and #27434 by @EngineerDali, @HelgeKrueger, and @gunchleoc)
|
||||
- Add option to use native Ruby driver for Redis through `REDIS_DRIVER=ruby` (#30717 by @vmstan)
|
||||
- Add support for libvips in addition to ImageMagick (#30090, #30590, #30597, #30632, #30857, #30869, #30858 and #32104 by @ClearlyClaire, @Gargron, and @mjankowski)\
|
||||
Server admins can now use libvips as a faster and lighter alternative to ImageMagick for processing user-uploaded images.\
|
||||
This requires libvips 8.13 or newer, and needs to be enabled with `MASTODON_USE_LIBVIPS=true`.\
|
||||
This is enabled by default in the official Docker images, and is intended to completely replace ImageMagick in the future.
|
||||
- Add validations to `Web::PushSubscription` (#30540 and #30542 by @ThisIsMissEm)
|
||||
- Add anchors to each authorized application in `/oauth/authorized_applications` (#31677 by @fowl2)
|
||||
- Add active animation to header settings button (#30221, #30307, and #30388 by @daudix)
|
||||
- Add OpenTelemetry instrumentation (#30130, #30322, #30353, #30350 and #31998 by @julianocosta89, @renchap, @robbkidd and @timetinytim)\
|
||||
See https://docs.joinmastodon.org/admin/config/#otel for documentation
|
||||
- Add API to get multiple accounts and statuses (#27871 and #30465 by @ClearlyClaire)\
|
||||
This adds `GET /api/v1/accounts` and `GET /api/v1/statuses` to the REST API, see https://docs.joinmastodon.org/methods/accounts/#index and https://docs.joinmastodon.org/methods/statuses/#index
|
||||
- Add support for CORS to `POST /oauth/revoke` (#31743 by @ClearlyClaire)
|
||||
- Add redirection back to previous page after site upload deletion (#30141 by @FawazFarid)
|
||||
- Add RFC8414 OAuth 2.0 server metadata (#29191 by @ThisIsMissEm)
|
||||
- Add loading indicator and empty result message to advanced interface search (#30085 by @ClearlyClaire)
|
||||
- Add `profile` OAuth 2.0 scope, allowing more limited access to user data (#29087 and #30357 by @ThisIsMissEm)
|
||||
- Add the role ID to the badge component (#29707 by @renchap)
|
||||
- Add diagnostic message for failure during CLI search deploy (#29462 by @mjankowski)
|
||||
- Add pagination `Link` headers on API accounts/statuses when pinned true (#29442 by @mjankowski)
|
||||
- Add support for specifying custom CA cert for Elasticsearch through `ES_CA_FILE` (#29122 and #29147 by @ClearlyClaire)
|
||||
- Add groundwork for annual reports for accounts (#28693 by @Gargron)\
|
||||
This lays the groundwork for a “year-in-review”/“wrapped” style report for local users, but is currently not in use.
|
||||
- Add notification email on invalid second authenticator (#28822 by @ClearlyClaire)
|
||||
- Add date of account deletion in list of accounts in the admin interface (#25640 by @tribela)
|
||||
- Add new emojis from `jdecked/twemoji` 15.0 (#28404 by @TheEssem)
|
||||
- Add configurable error handling in attachment batch deletion (#28184 by @vmstan)\
|
||||
This makes the S3 batch size configurable through the `S3_BATCH_DELETE_LIMIT` environment variable (defaults to 1000), and adds some retry logic, configurable through the `S3_BATCH_DELETE_RETRY` environment variable (defaults to 3).
|
||||
- Add VAPID public key to instance serializer (#28006 by @ThisIsMissEm)
|
||||
- Add support for serving JRD `/.well-known/host-meta.json` in addition to XRD host-meta (#32206 by @c960657)
|
||||
- Add `nodeName` and `nodeDescription` to nodeinfo `metadata` (#28079 by @6543)
|
||||
- Add Thai diacritics and tone marks in `HASHTAG_INVALID_CHARS_RE` (#26576 by @ppnplus)
|
||||
- Add variable delay before link verification of remote account links (#27774 by @ClearlyClaire)
|
||||
- Add support for invite codes in the registration API (#27805 by @ClearlyClaire)
|
||||
- Add HTML lang attribute to preview card descriptions (#27503 by @srapilly)
|
||||
- Add display of relevant account warnings to report action logs (#27425 by @ClearlyClaire)
|
||||
- Add validation of allowed schemes on preview card URLs (#27485 by @mjankowski)
|
||||
- Add token introspection without read scope to `/api/v1/apps/verify_credentials` (#27142 by @ThisIsMissEm)
|
||||
- Add support for cross-origin request to `/nodeinfo/2.0` (#27413 by @palant)
|
||||
- Add variable delay before link verification of remote account links (#27351 by @ClearlyClaire)
|
||||
- Add PWA shortcut to `/explore` page (#27235 by @jake-anto)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Change icons throughout the web interface** (#27385, #27539, #27555, #27579, #27700, #27817, #28519, #28709, #28064, #28775, #28780, #27924, #29294, #29395, #29537, #29569, #29610, #29612, #29649, #29844, #27780, #30974, #30963, #30962, #30961, #31362, #31363, #31359, #31371, #31360, #31512, #31511, #31525, #32153, and #32201 by @ClearlyClaire, @Gargron, @arbolitoloco1, @mjankowski, @nclm, @renchap, @ronilaukkarinen, and @zunda)\
|
||||
This changes all the interface icons from FontAwesome to Material Symbols for a more modern look, consistent with the official Mastodon Android app.\
|
||||
In addition, better care is given to pixel alignment, and icon variants are used to better highlight active/inactive state.
|
||||
- **Change design of compose form in web UI** (#28119, #29059, #29248, #29372, #29384, #29417, #29456, #29406, #29651, #29659, #31889 and #32033 by @ClearlyClaire, @Gargron, @eai04191, @hinaloe, and @ronilaukkarinen)\
|
||||
The compose form has been completely redesigned for a more modern and consistent look, as well as spelling out the chosen privacy setting and language name at all times.\
|
||||
As part of this, the “Unlisted” privacy setting has been renamed to “Quiet public”.
|
||||
- **Change design of modals in the web UI** (#29576, #29614, #29640, #29644, #30131, #30884, #31399, #31555, #31752, #31801, #31883, #31844, #31864, and #31943 by @ClearlyClaire, @Gargron, @tribela and @vmstan)\
|
||||
The mute, block, and domain block confirmation modals have been completely redesigned to be clearer and include more detailed information on the action to be performed.\
|
||||
They also have a more modern and consistent design, along with other confirmation modals in the application.
|
||||
- **Change colors throughout the web UI** (#29522, #29584, #29653, #29779, #29803, #29809, #29808, #29828, #31034, #31168, #31266, #31348, #31349, #31361, #31510 and #32128 by @ClearlyClaire, @Gargron, @mjankowski, @renchap, and @vmstan)
|
||||
- **Change onboarding prompt to follow suggestions carousel in web UI** (#28878, #29272, and #31912 by @Gargron)
|
||||
- **Change email templates** (#28416, #28755, #28814, #29064, #28883, #29470, #29607, #29761, #29760, #29879, #32073 and #32132 by @c960657, @ClearlyClaire, @Gargron, @hteumeuleu, and @mjankowski)\
|
||||
All emails to end-users have been completely redesigned with a fresh new look, providing more information while making them easier to read and keeping maximum compatibility across mail clients.
|
||||
- **Change follow recommendations algorithm** (#28314, #28433, #29017, #29108, #29306, #29550, #29619, and #31474 by @ClearlyClaire, @Gargron, @kernal053, @mjankowski, and @wheatear-dev)\
|
||||
This replaces the “past interactions” recommendation algorithm with a “friends of friends” algorithm that suggests accounts followed by people you follow, and a “similar profiles” algorithm that suggests accounts with a profile similar to your most recent follows.\
|
||||
In addition, the implementation has been significantly reworked, and all follow recommendations are now dismissable.\
|
||||
This change deprecates the `source` attribute in `Suggestion` entities in the REST API, and replaces it with the new [`sources` attribute](https://docs.joinmastodon.org/entities/Suggestion/#sources).
|
||||
- Change account search algorithm (#30803 by @Gargron)
|
||||
- **Change streaming server to use its own dependencies and its own docker image** (#24702, #27967, #26850, #28112, #28115, #28137, #28138, #28497, #28548, #30795, #31612, and #31615 by @TheEssem, @ThisIsMissEm, @jippi, @renchap, @timetinytim, and @vmstan)\
|
||||
In order to reduce the amount of runtime dependencies, the streaming server has been moved into a separate package and Docker image.\
|
||||
The `mastodon` image does not contain the streaming server anymore, as it has been moved to its own `mastodon-streaming` image.\
|
||||
Administrators may need to update their setup accordingly.
|
||||
- Change how content warnings and filters are displayed in web UI (#31365, and #31761 by @Gargron)
|
||||
- Change preview card processing to ignore `undefined` as canonical url (#31882 by @oneiros)
|
||||
- Change embedded posts to use web UI (#31766, #32135 and #32271 by @Gargron)
|
||||
- Change inner borders in media galleries in web UI (#31852 by @Gargron)
|
||||
- Change design of media attachments and profile media tab in web UI (#31807, #32048, #31967, #32217, #32224 and #32257 by @ClearlyClaire and @Gargron)
|
||||
- Change labels on thread indicators in web UI (#31806 by @Gargron)
|
||||
- Change label of "Data export" menu item in settings interface (#32099 by @c960657)
|
||||
- Change responsive break points on navigation panel in web UI (#32034 by @Gargron)
|
||||
- Change cursor to `not-allowed` on disabled buttons (#32076 by @mjankowski)
|
||||
- Change OAuth authorization prompt to not refer to apps as “third-party” (#32005 by @Gargron)
|
||||
- Change Mastodon to issue correct HTTP signatures by default (#31994 by @ClearlyClaire)
|
||||
- Change zoom icon in web UI (#29683 by @Gargron)
|
||||
- Change directory page to use URL query strings for options (#31980, #31977 and #31984 by @ClearlyClaire and @renchap)
|
||||
- Change report action buttons to be disabled when action has already been taken (#31773, #31822, and #31899 by @ClearlyClaire and @ThisIsMissEm)
|
||||
- Change width of columns in advanced web UI (#31762 by @Gargron)
|
||||
- Change design of unread conversations in web UI (#31763 by @Gargron)
|
||||
- Change Web UI to allow viewing and severing relationships with suspended accounts (#27667 by @ClearlyClaire)\
|
||||
This also adds a `with_suspended` parameter to `GET /api/v1/accounts/relationships` in the REST API.
|
||||
- Change preview card image size limit from 2MB to 8MB when using libvips (#31904 by @ClearlyClaire)
|
||||
- Change avatars border radius (#31390 by @renchap)
|
||||
- Change counters to be displayed on profile timelines in web UI (#30525 by @Gargron)
|
||||
- Change disabled buttons color in light mode to make the difference more visible (#30998 by @renchap)
|
||||
- Change design of people tab on explore in web UI (#30059 by @Gargron)
|
||||
- Change sidebar text in web UI (#30696 by @Gargron)
|
||||
- Change "Follow" to "Follow back" and "Mutual" when appropriate in web UI (#28452, #28465, and #31934 by @ClearlyClaire, @Gargron and @renchap)
|
||||
- Change media to be hidden/blurred by default in report modal (#28522 by @ClearlyClaire)
|
||||
- Change order of the "muting" and "blocking" list options in “Data Exports” (#26088 by @fixermark)
|
||||
- Change admin and moderation notes character limit from 500 to 2000 characters (#30288 by @ThisIsMissEm)
|
||||
- Change mute options to be in dropdown on muted users list in web UI (#30049 and #31315 by @ClearlyClaire and @Gargron)
|
||||
- Change out-of-band hashtags design in web UI (#29732 by @Gargron)
|
||||
- Change design of metadata underneath detailed posts in web UI (#29585, #29605, and #29648 by @ClearlyClaire and @Gargron)
|
||||
- Change action button to be last on profiles in web UI (#29533 and #29923 by @ClearlyClaire and @Gargron)
|
||||
- Change confirmation prompts in trending moderation interface to be more specific (#19626 by @tribela)
|
||||
- Change “Trends” moderation menu to “Recommendations & Trends” and move follow recommendations there (#31292 by @ThisIsMissEm)
|
||||
- Change irrelevant fields in account cleanup settings to be disabled unless automatic cleanup is enabled (#26562 by @c960657)
|
||||
- Change dropdown menu icon to not be replaced by close icon when open in web UI (#29532 by @Gargron)
|
||||
- Change back button to always appear in advanced web UI (#29551 and #29669 by @Gargron)
|
||||
- Change border of active compose field search inputs (#29832 and #29839 by @vmstan)
|
||||
- Change instances of Nokogiri HTML4 parsing to HTML5 (#31812, #31815, #31813, and #31814 by @flavorjones)
|
||||
- Change link detection to allow `@` at the end of an URL (#31124 by @adamniedzielski)
|
||||
- Change User-Agent to use Mastodon as the product, and http.rb as platform details (#31192 by @ClearlyClaire)
|
||||
- Change layout and wording of the Content Retention server settings page (#27733 by @vmstan)
|
||||
- Change unconfirmed users to be kept for one week instead of two days (#30285 by @renchap)
|
||||
- Change maximum page size for Admin Domain Management APIs from 200 to 500 (#31253 by @ThisIsMissEm)
|
||||
- Change database pool size to default to Sidekiq concurrency settings in Sidekiq processes (#26488 by @sinoru)
|
||||
- Change alt text to empty string for avatars (#21875 by @jasminjohal)
|
||||
- Change Docker images to use custom-built libvips and ffmpeg (#30571, #30569, and #31498 by @vmstan)
|
||||
- Change external links in the admin audit log to plain text or local administration pages (#27139 and #27150 by @ClearlyClaire and @ThisIsMissEm)
|
||||
- Change YJIT to be enabled when available (#30310 and #27283 by @ClearlyClaire and @mjankowski)\
|
||||
Enable Ruby's built-in just-in-time compiler. This improves performances substantially, at the cost of a slightly increased memory usage.
|
||||
- Change `.env` file loading from deprecated `dotenv-rails` gem to `dotenv` gem (#29173 and #30121 by @mjankowski)\
|
||||
This should have no effect except in the unlikely case an environment variable included a newline.
|
||||
- Change “Panjabi” language name to the more common spelling “Punjabi” (#27117 by @gunchleoc)
|
||||
- Change encryption of OTP secrets to use ActiveRecord Encryption (#29831, #28325, #30151, #30202, #30340, and #30344 by @ClearlyClaire and @mjankowski)\
|
||||
This requires a manual step from administrators of existing servers. Indeed, they need to generate new secrets, which can be done using `bundle exec rails db:encryption:init`.\
|
||||
Furthermore, there is a risk that the introduced migration fails if the server was misconfigured in the past. If that happens, the migration error will include the relevant information.
|
||||
- Change `/api/v1/announcements` to return regular `Status` entities (#26736 by @ClearlyClaire)
|
||||
- Change imports to convert case-insensitive fields to lowercase (#29739 and #29740 by @ThisIsMissEm)
|
||||
- Change stats in the admin interface to be inclusive of the full selected range, from beginning of day to end of day (#29416 and #29841 by @mjankowski)
|
||||
- Change materialized views to be refreshed concurrently to avoid locks (#29015 by @Gargron)
|
||||
- Change compose form to use server-provided post character and poll options limits (#28928 and #29490 by @ClearlyClaire and @renchap)
|
||||
- Change streaming server logging from `npmlog` to `pino` and `pino-http` (#27828 by @ThisIsMissEm)\
|
||||
This changes the Mastodon streaming server log format, so this might be considered a breaking change if you were parsing the logs.
|
||||
- Change media “ALT” label to use a specific CSS class (#28777 by @ClearlyClaire)
|
||||
- Change streaming API host to not be overridden to localhost in development mode (#28557 by @ClearlyClaire)
|
||||
- Change cookie rotator to use SHA1 digest for new cookies (#27392 by @ClearlyClaire)\
|
||||
Note that this requires that no pre-4.2.0 Mastodon web server is running when this code is deployed, as those would not understand the new cookies.\
|
||||
Therefore, zero-downtime updates are only supported if you're coming from 4.2.0 or newer. If you want to skip Mastodon 4.2, you will need to completely stop Mastodon services before updating.
|
||||
- Change preview card deletes to be done using batch method (#28183 by @vmstan)
|
||||
- Change `img-src` and `media-src` CSP directives to not include `https:` (#28025 and #28561 by @ClearlyClaire)
|
||||
- Change self-destruct procedure (#26439, #29049, and #29420 by @ClearlyClaire and @zunda)\
|
||||
Instead of enqueuing deletion jobs immediately, `tootctl self-destruct` now outputs a value for the `SELF_DESTRUCT` environment variable, which puts a server in self-destruct mode, processing deletions in the background, while giving users access to their export archives.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove unused E2EE messaging code and related `crypto` OAuth scope (#31193, #31945, #31963, and #31964 by @ClearlyClaire and @mjankowski)
|
||||
- Remove StatsD integration (replaced by OpenTelemetry) (#30240 by @mjankowski)
|
||||
- Remove `CacheBuster` default options (#30718 by @mjankowski)
|
||||
- Remove home marker updates from the Web UI (#22721 by @davbeck)\
|
||||
The web interface was unconditionally updating the home marker to the most recent received post, discarding any value set by other clients, thus making the feature unreliable.
|
||||
- Remove support for Ruby 3.0 (reaching EOL) (#29702 by @mjankowski)
|
||||
- Remove setting for unfollow confirmation modal (#29373 by @ClearlyClaire)\
|
||||
Instead, the unfollow confirmation modal will always be displayed.
|
||||
- Remove support for Capistrano (#27295 and #30009 by @mjankowski and @renchap)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fix link preview cards not always preserving the original URL from the status** (#27312 by @Gargron)
|
||||
- Fix log out from user menu not working on Safari (#31402 by @renchap)
|
||||
- Fix various issues when in link preview card generation (#28748, #30017, #30362, #30173, #30853, #30929, #30933, #30957, #30987, and #31144 by @adamniedzielski, @oneiros, @phocks, @timothyjrogers, and @tribela)
|
||||
- Fix handling of missing links in Webfinger responses (#31030 by @adamniedzielski)
|
||||
- Fix error when accepting an appeal for sensitive posts deleted in the meantime (#32037 by @ClearlyClaire)
|
||||
- Fix error when encountering reblog of deleted post in feed rebuild (#32001 by @ClearlyClaire)
|
||||
- Fix Safari browser glitch related to horizontal scrolling (#31960 by @Gargron)
|
||||
- Fix unresolvable mentions sometimes preventing processing incoming posts (#29215 by @tribela and @ClearlyClaire)
|
||||
- Fix too many requests caused by relationship look-ups in web UI (#32042 by @Gargron)
|
||||
- Fix links for reblogs in moderation interface (#31979 by @ClearlyClaire)
|
||||
- Fix the appearance of avatars when they do not load (#31966 and #32270 by @Gargron and @renchap)
|
||||
- Fix spurious error notifications for aborted requests in web UI (#31952 by @c960657)
|
||||
- Fix HTTP 500 error in `/api/v1/polls/:id/votes` when required `choices` parameter is missing (#25598 by @danielmbrasil)
|
||||
- Fix security context sometimes not being added in LD-Signed activities (#31871 by @ClearlyClaire)
|
||||
- Fix cross-origin loading of `inert.css` polyfill (#30687 by @louis77)
|
||||
- Fix wrapping in dashboard quick access buttons (#32043 by @renchap)
|
||||
- Fix recently used tags hint being displayed in profile edition page when there is none (#32120 by @mjankowski)
|
||||
- Fix checkbox lists on narrow screens in the settings interface (#32112 by @mjankowski)
|
||||
- Fix the position of status action buttons being affected by interaction counters (#32084 by @renchap)
|
||||
- Fix the summary of converted ActivityPub object types to be treated as HTML (#28629 by @Menrath)
|
||||
- Fix cutoff of instance name in sign-up form (#30598 by @oneiros)
|
||||
- Fix invalid date searches returning 503 errors (#31526 by @notchairmk)
|
||||
- Fix invalid `visibility` values in `POST /api/v1/statuses` returning 500 errors (#31571 by @c960657)
|
||||
- Fix some components re-rendering spuriously in web UI (#31879 and #31881 by @ClearlyClaire and @Gargron)
|
||||
- Fix sort order of moderation notes on Reports and Accounts (#31528 by @ThisIsMissEm)
|
||||
- Fix email language when recipient has no selected locale (#31747 by @ClearlyClaire)
|
||||
- Fix frequently-used languages not correctly updating in the web UI (#31386 by @c960657)
|
||||
- Fix `POST /api/v1/statuses` silently ignoring invalid `media_ids` parameter (#31681 by @c960657)
|
||||
- Fix handling of the `BIND` environment variable in the streaming server (#31624 by @ThisIsMissEm)
|
||||
- Fix empty `aria-hidden` attribute value in logo resources area (#30570 by @mjankowski)
|
||||
- Fix “Redirect URI” field not being marked as required in “New application” form (#30311 by @ThisIsMissEm)
|
||||
- Fix right-to-left text in preview cards (#30930 by @ClearlyClaire)
|
||||
- Fix rack attack `match_type` value typo in logging config (#30514 by @mjankowski)
|
||||
- Fix various cases of duplicate, missing, or inconsistent borders or scrollbar styles (#31068, #31286, #31268, #31275, #31284, #31305, #31346, #31372, #31373, #31389, #31432, #31391, #31445, #32091, #32147 and #32137 by @ClearlyClaire, @mjankowski, @valtlai and @vmstan)
|
||||
- Fix editing description of media uploads with custom thumbnails (#32221 by @ClearlyClaire)
|
||||
- Fix race condition in `POST /api/v1/push/subscription` (#30166 by @ClearlyClaire)
|
||||
- Fix post deletion not being delayed when those are part of an account warning (#30163 by @ClearlyClaire)
|
||||
- Fix rendering error on `/start` when not logged in (#30023 by @timothyjrogers)
|
||||
- Fix unneeded requests to blocked domains when receiving relayed signed activities from them (#31161 by @ClearlyClaire)
|
||||
- Fix logo pushing header buttons out of view on certain conditions in mobile layout (#29787 by @ClearlyClaire)
|
||||
- Fix notification-related records not being reattributed when merging accounts (#29694 by @ClearlyClaire)
|
||||
- Fix results/query in `api/v1/featured_tags/suggestions` (#29597 by @mjankowski)
|
||||
- Fix distracting and confusing always-showing scrollbar track in boost confirmation modal (#31524 by @ClearlyClaire)
|
||||
- Fix being able to upload more than 4 media attachments in some cases (#29183 by @mashirozx)
|
||||
- Fix preview card player getting embedded when clicking on the external link button (#29457 by @ClearlyClaire)
|
||||
- Fix full date display not respecting the locale 12/24h format (#29448 by @renchap)
|
||||
- Fix filters title and keywords overflow (#29396 by @GeopJr)
|
||||
- Fix incorrect date format in “Follows and followers” (#29390 by @JasonPunyon)
|
||||
- Fix navigation item active highlight for some paths (#32159 by @mjankowski)
|
||||
- Fix “Edit media” modal sizing and layout when space-constrained (#27095 by @ronilaukkarinen)
|
||||
- Fix modal container bounds (#29185 by @nico3333fr)
|
||||
- Fix inefficient HTTP signature parsing using regexps and `StringScanner` (#29133 by @ClearlyClaire)
|
||||
- Fix moderation report updates through `PUT /api/v1/admin/reports/:id` not being logged in the audit log (#29044, #30342, and #31033 by @mjankowski, @tribela, and @vmstan)
|
||||
- Fix moderation interface allowing to select rule violation when there are no server rules (#31458 by @ThisIsMissEm)
|
||||
- Fix redirection from paths with url-encoded `@` to their decoded form (#31184 by @timothyjrogers)
|
||||
- Fix Trending Tags pending review having an unstable sort order (#31473 by @ThisIsMissEm)
|
||||
- Fix the emoji dropdown button always opening the dropdown instead of behaving like a toggle (#29012 by @jh97uk)
|
||||
- Fix processing of incoming posts with bearcaps (#26527 by @kmycode)
|
||||
- Fix support for IPv6 redis connections in streaming (#31229 by @ThisIsMissEm)
|
||||
- Fix search form re-rendering spuriously in web UI (#28876 by @Gargron)
|
||||
- Fix `RedownloadMediaWorker` not being called on transient S3 failure (#28714 by @ClearlyClaire)
|
||||
- Fix ISO code for Canadian French from incorrect `fr-QC` to `fr-CA` (#26015 by @gunchleoc)
|
||||
- Fix `.opus` file uploads being misidentified by Paperclip (#28580 by @vmstan)
|
||||
- Fix loading local accounts with extraneous domain part in WebUI (#28559 by @ClearlyClaire)
|
||||
- Fix destructive actions in dropdowns not using error color in light theme (#28484 by @logicalmoody)
|
||||
- Fix call to inefficient `delete_matched` cache method in domain blocks (#28374 by @ClearlyClaire)
|
||||
- Fix status edits not always being streamed to mentioned users (#28324 by @ClearlyClaire)
|
||||
- Fix onboarding step descriptions being truncated on narrow screens (#28021 by @ClearlyClaire)
|
||||
- Fix duplicate IDs in relationships and familiar_followers APIs (#27982 by @KevinBongart)
|
||||
- Fix modal content not being selectable (#27813 by @pajowu)
|
||||
- Fix Web UI not displaying appropriate explanation when a user hides their follows/followers (#27791 by @ClearlyClaire)
|
||||
- Fix format-dependent redirects being cached regardless of requested format (#27632 by @ClearlyClaire)
|
||||
- Fix confusing screen when visiting a confirmation link for an already-confirmed email (#27368 by @ClearlyClaire)
|
||||
- Fix explore page reloading when you navigate back to it in web UI (#27489 by @Gargron)
|
||||
- Fix missing redirection from `/home` to `/deck/home` in the advanced interface (#27378 by @Signez)
|
||||
- Fix empty environment variables not using default nil value (#27400 by @renchap)
|
||||
- Fix language sorting in settings (#27158 by @gunchleoc)
|
||||
|
||||
_For previous changes, review the [stable-4.2 branch](https://github.com/mastodon/mastodon/blob/stable-4.2/CHANGELOG.md)_
|
||||
_For previous changes, review the [stable-4.3 branch](https://github.com/mastodon/mastodon/blob/stable-4.3/CHANGELOG.md)_
|
||||
|
||||
@@ -210,7 +210,7 @@ FROM media-build AS libvips
|
||||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.18.2
|
||||
ARG VIPS_VERSION=8.18.3
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
||||
76
Gemfile.lock
76
Gemfile.lock
@@ -10,7 +10,7 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.18)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
@@ -99,8 +99,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1255.0)
|
||||
aws-sdk-core (3.250.0)
|
||||
aws-partitions (1.1259.0)
|
||||
aws-sdk-core (3.252.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -108,10 +108,10 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.128.0)
|
||||
aws-sdk-kms (1.129.0)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.224.0)
|
||||
aws-sdk-s3 (1.225.1)
|
||||
aws-sdk-core (~> 3, >= 3.248.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -132,7 +132,7 @@ GEM
|
||||
binding_of_caller (2.0.0)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.24.4)
|
||||
bootsnap (1.24.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
@@ -159,7 +159,7 @@ GEM
|
||||
cbor (0.5.10.2)
|
||||
cgi (0.5.1)
|
||||
charlock_holmes (0.7.9)
|
||||
chewy (8.0.1)
|
||||
chewy (8.3.1)
|
||||
activesupport (>= 7.2)
|
||||
elasticsearch (>= 8.14, < 9.0)
|
||||
elasticsearch-dsl
|
||||
@@ -178,13 +178,14 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (2.1.0)
|
||||
css_parser (3.0.0)
|
||||
addressable
|
||||
ssrf_filter (~> 1.5)
|
||||
csv (3.3.5)
|
||||
database_cleaner-active_record (2.2.2)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
database_cleaner-core (2.1.0)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
@@ -209,12 +210,12 @@ GEM
|
||||
activerecord (>= 7.0, < 9.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.6.20240107)
|
||||
doorkeeper (5.9.0)
|
||||
doorkeeper (5.9.2)
|
||||
railties (>= 5)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.4.1)
|
||||
elastic-transport (8.5.1)
|
||||
elastic-transport (8.5.2)
|
||||
faraday (< 3)
|
||||
multi_json
|
||||
elasticsearch (8.19.3)
|
||||
@@ -224,13 +225,11 @@ GEM
|
||||
elasticsearch-api (8.19.3)
|
||||
multi_json
|
||||
elasticsearch-dsl (0.1.10)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
excon (1.4.2)
|
||||
excon (1.5.0)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.8.0)
|
||||
@@ -243,12 +242,12 @@ GEM
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.3)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.1)
|
||||
ffi (1.17.4)
|
||||
ffi-compiler (1.3.2)
|
||||
ffi-compiler (1.4.2)
|
||||
ffi (>= 1.15.5)
|
||||
rake
|
||||
flatware (2.4.0)
|
||||
@@ -273,15 +272,15 @@ GEM
|
||||
formatador (1.2.3)
|
||||
reline
|
||||
forwardable (1.4.0)
|
||||
fugit (1.12.1)
|
||||
fugit (1.12.2)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.34.1)
|
||||
google-protobuf (4.35.0)
|
||||
bigdecimal
|
||||
rake (~> 13.3)
|
||||
googleapis-common-protos-types (1.22.0)
|
||||
googleapis-common-protos-types (1.23.0)
|
||||
google-protobuf (~> 4.26)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
@@ -354,9 +353,9 @@ GEM
|
||||
azure-blob (~> 0.5.2)
|
||||
hashie (~> 5.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.7)
|
||||
json (2.19.8)
|
||||
json-canonicalization (1.0.0)
|
||||
json-jwt (1.17.0)
|
||||
json-jwt (1.17.1)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
@@ -452,8 +451,8 @@ GEM
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.20.1)
|
||||
msgpack (1.8.1)
|
||||
multi_json (1.21.1)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
@@ -491,10 +490,9 @@ GEM
|
||||
omniauth_openid_connect (0.8.0)
|
||||
omniauth (>= 1.9, < 3)
|
||||
openid_connect (~> 2.2)
|
||||
openid_connect (2.3.1)
|
||||
openid_connect (2.5.0)
|
||||
activemodel
|
||||
attr_required (>= 1.0.0)
|
||||
email_validator
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.16)
|
||||
@@ -504,7 +502,7 @@ GEM
|
||||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (4.0.1)
|
||||
openssl (4.0.2)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.10.0)
|
||||
@@ -523,7 +521,7 @@ GEM
|
||||
opentelemetry-helpers-sql-processor (0.5.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.8.0)
|
||||
opentelemetry-instrumentation-action_mailer (0.8.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-action_pack (0.18.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29)
|
||||
@@ -535,7 +533,7 @@ GEM
|
||||
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||
opentelemetry-instrumentation-active_record (0.13.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-active_storage (0.5.0)
|
||||
opentelemetry-instrumentation-active_storage (0.5.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-active_support (0.12.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
@@ -582,7 +580,7 @@ GEM
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.37.1)
|
||||
opentelemetry-semantic_conventions (1.39.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.3)
|
||||
@@ -620,11 +618,11 @@ GEM
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
psych (5.3.1)
|
||||
psych (5.4.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.1)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -721,7 +719,8 @@ GEM
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.7.0)
|
||||
rouge (5.0.0)
|
||||
strscan (~> 3.1)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -779,15 +778,16 @@ GEM
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.35.3)
|
||||
rubocop-rails (2.35.4)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.9.0)
|
||||
rubocop-rspec (3.10.2)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
regexp_parser (>= 2.0)
|
||||
rubocop (~> 1.86, >= 1.86.2)
|
||||
rubocop-rspec_rails (2.32.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
@@ -844,6 +844,7 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov-lcov (0.9.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
ssrf_filter (1.5.0)
|
||||
stackprof (0.2.28)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
@@ -853,6 +854,7 @@ GEM
|
||||
stringio (3.2.0)
|
||||
strong_migrations (2.8.0)
|
||||
activerecord (>= 7.2)
|
||||
strscan (3.1.8)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
@@ -929,7 +931,7 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.1)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -1100,4 +1102,4 @@ RUBY VERSION
|
||||
ruby 4.0.5
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.11
|
||||
4.0.13
|
||||
|
||||
11
SECURITY.md
11
SECURITY.md
@@ -13,9 +13,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| < 4.3 | No |
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| < 4.4 | No |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::EmailSubscriptions::AccountsController < Admin::BaseController
|
||||
before_action :require_enabled!
|
||||
before_action :set_account
|
||||
|
||||
def show
|
||||
authorize :email_subscription, :show?
|
||||
@email_subscriptions_count = EmailSubscription.where(account: @account).count
|
||||
@email_subscriptions = EmailSubscription.where(account: @account).page(params[:page])
|
||||
end
|
||||
|
||||
def enable
|
||||
authorize :email_subscription, :enable?
|
||||
@account.user.settings['email_subscriptions'] = true
|
||||
@account.user.save!
|
||||
redirect_to admin_email_subscriptions_account_path(@account.id)
|
||||
end
|
||||
|
||||
def disable
|
||||
authorize :email_subscription, :disable?
|
||||
@account.user.settings['email_subscriptions'] = false
|
||||
@account.user.save!
|
||||
redirect_to admin_email_subscriptions_account_path(@account.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_enabled!
|
||||
raise ActionController::RoutingError, 'Feature disabled' unless Rails.application.config.x.email_subscriptions
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Admin::EmailSubscriptionsController < Admin::BaseController
|
||||
before_action :set_email_subscription, only: :destroy
|
||||
|
||||
def index
|
||||
authorize :email_subscription, :index?
|
||||
|
||||
@@ -9,6 +11,12 @@ class Admin::EmailSubscriptionsController < Admin::BaseController
|
||||
@accounts = Account.local.where.associated(:email_subscriptions).includes(:user)
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize :email_subscription, :destroy?
|
||||
@email_subscription.destroy!
|
||||
redirect_to admin_email_subscriptions_account_path(@email_subscription.account_id)
|
||||
end
|
||||
|
||||
def disable
|
||||
authorize :email_subscription, :disable?
|
||||
Setting.email_subscriptions = false
|
||||
@@ -20,4 +28,10 @@ class Admin::EmailSubscriptionsController < Admin::BaseController
|
||||
Admin::EmailSubscriptionsPurgeWorker.perform_async
|
||||
redirect_to admin_email_subscriptions_path, notice: I18n.t('admin.email_subscriptions.purged_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_email_subscription
|
||||
@email_subscription = EmailSubscription.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -100,4 +100,8 @@ class Api::BaseController < ApplicationController
|
||||
def respond_with_error(code)
|
||||
render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code
|
||||
end
|
||||
|
||||
def alpha_path?
|
||||
request.path.starts_with?('/api/v1_alpha')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
class Api::V1::CollectionItemsController < Api::BaseController
|
||||
include Authorization
|
||||
include DeprecationConcern
|
||||
|
||||
deprecate_api '2026-06-10', if: :alpha_path?
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:collections' }
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
class Api::V1::CollectionsController < Api::BaseController
|
||||
include Authorization
|
||||
include DeprecationConcern
|
||||
|
||||
DEFAULT_COLLECTIONS_LIMIT = 40
|
||||
MAX_COLLECTIONS_LIMIT = 100
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
|
||||
render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422
|
||||
end
|
||||
|
||||
deprecate_api '2026-06-10', if: :alpha_path?
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy]
|
||||
|
||||
@@ -73,7 +77,7 @@ class Api::V1::CollectionsController < Api::BaseController
|
||||
.with_tag
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT, MAX_COLLECTIONS_LIMIT))
|
||||
@collections = @collections.discoverable unless @account == current_account
|
||||
end
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController
|
||||
:filter_not_following,
|
||||
:filter_not_followers,
|
||||
:filter_new_accounts,
|
||||
:filter_private_mentions
|
||||
:filter_private_mentions,
|
||||
:filter_bots
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,7 +32,8 @@ class Api::V2::Notifications::PoliciesController < Api::BaseController
|
||||
:for_not_followers,
|
||||
:for_new_accounts,
|
||||
:for_private_mentions,
|
||||
:for_limited_accounts
|
||||
:for_limited_accounts,
|
||||
:for_bots
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
13
app/controllers/auth/acceptances_controller.rb
Normal file
13
app/controllers/auth/acceptances_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::AcceptancesController < ApplicationController
|
||||
def create
|
||||
redirect_to new_user_registration_path(registration_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def registration_params
|
||||
params.permit(:accept, :invite_code).compact_blank
|
||||
end
|
||||
end
|
||||
@@ -7,10 +7,13 @@ class AuthorizeInteractionsController < ApplicationController
|
||||
before_action :set_resource
|
||||
|
||||
def show
|
||||
if @resource.is_a?(Account)
|
||||
case @resource
|
||||
when Account
|
||||
redirect_to web_url("@#{@resource.pretty_acct}")
|
||||
elsif @resource.is_a?(Status)
|
||||
when Status
|
||||
redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}")
|
||||
when Collection
|
||||
redirect_to web_url("collections/#{@resource.id}")
|
||||
else
|
||||
not_found
|
||||
end
|
||||
|
||||
@@ -10,6 +10,7 @@ class CollectionsController < ApplicationController
|
||||
|
||||
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_collection
|
||||
before_action :redirect_to_canonical_url
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
|
||||
@@ -18,7 +19,6 @@ class CollectionsController < ApplicationController
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
expires_in expiration_duration, public: true unless user_signed_in?
|
||||
render template: 'home/index'
|
||||
end
|
||||
|
||||
format.json do
|
||||
@@ -46,6 +46,10 @@ class CollectionsController < ApplicationController
|
||||
not_found
|
||||
end
|
||||
|
||||
def redirect_to_canonical_url
|
||||
redirect_to collection_path(@collection) if request.format.html? && request.path.starts_with?('/ap/')
|
||||
end
|
||||
|
||||
def expiration_duration
|
||||
recently_updated = @collection.updated_at > 15.minutes.ago
|
||||
recently_updated ? 30.seconds : 5.minutes
|
||||
|
||||
@@ -13,6 +13,7 @@ class Settings::ImportsController < Settings::BaseController
|
||||
domain_blocking: 'blocked_domains_failures.csv',
|
||||
bookmarks: 'bookmarks_failures.csv',
|
||||
lists: 'lists_failures.csv',
|
||||
custom_filters: 'custom_filters_failures.json',
|
||||
}.freeze
|
||||
|
||||
TYPE_TO_HEADERS_MAP = {
|
||||
@@ -61,6 +62,21 @@ class Settings::ImportsController < Settings::BaseController
|
||||
|
||||
send_data export_data, filename: filename
|
||||
end
|
||||
|
||||
format.json do
|
||||
filename = TYPE_TO_FILENAME_MAP[@bulk_import.type.to_sym]
|
||||
|
||||
data_collection = { custom_filters: [] }
|
||||
@bulk_import.rows.find_each do |row|
|
||||
case @bulk_import.type.to_sym
|
||||
when :custom_filters
|
||||
data_collection[:custom_filters] << row.data
|
||||
end
|
||||
end
|
||||
export_data = JSON.generate(data_collection)
|
||||
|
||||
send_data export_data, filename: filename
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ module ContextHelper
|
||||
memorial: { 'toot' => 'http://joinmastodon.org/ns#', 'memorial' => 'toot:memorial' },
|
||||
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
|
||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@container' => '@set' } },
|
||||
profile_settings: {
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'showFeatured' => 'toot:showFeatured',
|
||||
@@ -42,6 +42,7 @@ module ContextHelper
|
||||
interaction_policies: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||
'canFeature' => { '@id' => 'https://w3id.org/fep/7aa9#canFeature', '@type' => '@id' },
|
||||
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
|
||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||
@@ -52,6 +53,22 @@ module ContextHelper
|
||||
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
|
||||
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
|
||||
},
|
||||
feature_requests: { 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest' },
|
||||
featured_collections: {
|
||||
'FeaturedCollection' => 'https://w3id.org/fep/7aa9#FeaturedCollection',
|
||||
'FeaturedItem' => 'https://w3id.org/fep/7aa9#FeaturedItem',
|
||||
'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest',
|
||||
'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization',
|
||||
'topic' => { '@id' => 'https://w3id.org/fep/7aa9#topic', '@type' => '@id' },
|
||||
'featuredObject' => { '@id' => 'https://w3id.org/fep/7aa9#featuredObject', '@type' => '@id' },
|
||||
'featureAuthorization' => { '@id' => 'https://w3id.org/fep/7aa9#featureAuthorization', '@type' => '@id' },
|
||||
},
|
||||
feature_authorizations: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'FeatureAuthorization' => 'https://w3id.org/fep/7aa9#FeatureAuthorization',
|
||||
'interactingObject' => { '@id' => 'gts:interactingObject', '@type' => '@id' },
|
||||
'interactionTarget' => { '@id' => 'gts:interactionTarget', '@type' => '@id' },
|
||||
},
|
||||
}.freeze
|
||||
|
||||
def full_context
|
||||
|
||||
@@ -199,8 +199,10 @@ module LanguagesHelper
|
||||
kab: ['Kabyle', 'Taqbaylit'].freeze,
|
||||
ldn: ['Láadan', 'Láadan'].freeze,
|
||||
lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
|
||||
lzz: ['Lazuri', 'ლაზური ნენა'].freeze,
|
||||
moh: ['Mohawk', 'Kanienʼkéha'].freeze,
|
||||
nds: ['Low German', 'Plattdüütsch'].freeze,
|
||||
ota: ['Ottoman Turkish', 'لسان عثمانی'].freeze,
|
||||
pdc: ['Pennsylvania Dutch', 'Pennsilfaani-Deitsch'].freeze,
|
||||
sco: ['Scots', 'Scots'].freeze,
|
||||
sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,
|
||||
@@ -209,6 +211,7 @@ module LanguagesHelper
|
||||
tok: ['Toki Pona', 'toki pona'].freeze,
|
||||
vai: ['Vai', 'ꕙꔤ'].freeze,
|
||||
xal: ['Kalmyk', 'Хальмг келн'].freeze,
|
||||
xmf: ['Mingrelian', 'მარგალური ნინა'].freeze,
|
||||
zba: ['Balaibalan', 'باليبلن'].freeze,
|
||||
zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
|
||||
}.freeze
|
||||
|
||||
@@ -57,6 +57,10 @@ module SettingsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def time_zone_options
|
||||
ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.now.formatted_offset}) #{tz.name}", tz.tzinfo.name] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def links_for_featured_tags(tags)
|
||||
|
||||
@@ -181,6 +181,8 @@ async function loaded() {
|
||||
|
||||
truncateRuleHints();
|
||||
|
||||
applyRailsA11yPatches();
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
|
||||
if (reactComponents.length > 0) {
|
||||
@@ -515,6 +517,88 @@ on('click', '.rules-list button', ({ target }) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch accessibility issues caused by Ruby Gems that
|
||||
* don't produce accessible markup (simple-forms & simple-navigation)
|
||||
*/
|
||||
function applyRailsA11yPatches() {
|
||||
/**
|
||||
* Mark current navigation item with aria-current
|
||||
*/
|
||||
const activeNavLink = document.querySelector(
|
||||
'.simple-navigation-active-leaf a.selected',
|
||||
);
|
||||
activeNavLink?.setAttribute('aria-current', 'page');
|
||||
|
||||
/**
|
||||
* Hides the asterisk added to labels of required form fields
|
||||
* from assistive tech. (Those fields already have the `required` attribute)
|
||||
*/
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('.simple_form label.required abbr')
|
||||
.forEach((element) => {
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
/**
|
||||
* Associate form field hints with their inputs via aria-describedby
|
||||
*/
|
||||
document
|
||||
.querySelectorAll<HTMLDivElement>('.simple_form .field_with_hint')
|
||||
.forEach((field) => {
|
||||
const inputs = field.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>("input[type='text'], input[type='checkbox'], textarea");
|
||||
|
||||
const hint = field.querySelector<HTMLDivElement>('.hint');
|
||||
|
||||
// Bail out if there are more than one input as
|
||||
// the association can't be safely made.
|
||||
if (inputs.length !== 1 || !inputs[0] || !hint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = inputs[0];
|
||||
const inputId = input.getAttribute('id');
|
||||
const hintId = `${inputId}_hint`;
|
||||
|
||||
input.setAttribute('aria-describedby', hintId);
|
||||
hint.setAttribute('id', hintId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add fieldset-like group labels ("legends") to the date-of-birth selector
|
||||
* and groups of radio buttons
|
||||
*/
|
||||
const groups = document.querySelectorAll<HTMLDivElement>(
|
||||
'.simple_form .date_of_birth, .simple_form .input.with_label.radio_buttons',
|
||||
);
|
||||
groups.forEach((groupWrapper) => {
|
||||
// This is the element serving as the label of the group.
|
||||
const groupLabel = groupWrapper.querySelector<HTMLLabelElement>('label');
|
||||
const labelWithId =
|
||||
groupWrapper.querySelector<HTMLLabelElement>('label[for]');
|
||||
const groupHint = groupWrapper.querySelector<HTMLDivElement>('.hint');
|
||||
|
||||
// We need a unique ID to generate the aria associations. If `groupLabel`
|
||||
// doesn't have one, we just take the first label with a `for` attribute
|
||||
// that we can find, which is fine because we'll modify it before use.
|
||||
const inputId =
|
||||
groupLabel?.getAttribute('for') ?? labelWithId?.getAttribute('for');
|
||||
const labelId = `${inputId}_label`;
|
||||
const hintId = `${inputId}_hint`;
|
||||
|
||||
groupLabel?.setAttribute('id', labelId);
|
||||
groupHint?.setAttribute('id', hintId);
|
||||
|
||||
groupWrapper.setAttribute('role', 'group');
|
||||
groupWrapper.setAttribute('aria-labelledby', labelId);
|
||||
if (groupHint) {
|
||||
groupWrapper.setAttribute('aria-describedby', hintId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
ready(loaded).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { importFetchedStatuses } from '@/mastodon/actions/importer';
|
||||
import { hydrateStore } from '@/mastodon/actions/store';
|
||||
import type { ApiAnnualReportResponse } from '@/mastodon/api/annual_report';
|
||||
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||
import { Router } from '@/mastodon/components/router';
|
||||
import { WrapstodonSharedPage } from '@/mastodon/features/annual_report/shared_page';
|
||||
import { IntlProvider, loadLocale } from '@/mastodon/locales';
|
||||
@@ -52,7 +53,9 @@ function loaded() {
|
||||
<IntlProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<Router>
|
||||
<WrapstodonSharedPage />
|
||||
<FocusTargetProvider>
|
||||
<WrapstodonSharedPage />
|
||||
</FocusTargetProvider>
|
||||
</Router>
|
||||
</ReduxProvider>
|
||||
</IntlProvider>,
|
||||
|
||||
@@ -93,7 +93,7 @@ const messages = defineMessages({
|
||||
|
||||
export const ensureComposeIsVisible = (getState) => {
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
browserHistory.push('/publish');
|
||||
browserHistory.push('/publish', { focusTarget: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,7 +292,10 @@ export function submitCompose(successCallback) {
|
||||
message: statusId === null ? messages.published : messages.saved,
|
||||
action: messages.open,
|
||||
dismissAfter: 10000,
|
||||
onClick: () => browserHistory.push(`/@${response.data.account.username}/${response.data.id}`),
|
||||
onClick: () => browserHistory.push(
|
||||
`/@${response.data.account.username}/${response.data.id}`,
|
||||
{ focusTarget: 'detailed-status' }
|
||||
),
|
||||
}));
|
||||
}).catch(function (error) {
|
||||
dispatch(submitComposeFail(error));
|
||||
@@ -339,11 +342,6 @@ export function uploadCompose(files) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getState().getIn(['compose', 'poll'])) {
|
||||
dispatch(showAlert({ message: messages.uploadErrorPoll }));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(uploadComposeRequest());
|
||||
|
||||
for (const [i, file] of Array.from(files).entries()) {
|
||||
|
||||
@@ -18,14 +18,10 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
|
||||
);
|
||||
|
||||
// If there's a mismatch, re-import all custom emojis.
|
||||
if (existingEmojis.length < emojis.length) {
|
||||
if (existingEmojis.length > 0 && existingEmojis.length < emojis.length) {
|
||||
await clearCache('custom');
|
||||
await loadCustomEmoji();
|
||||
|
||||
const { reloadCustomEmojis } =
|
||||
await import('@/mastodon/features/emoji/picker');
|
||||
await reloadCustomEmojis();
|
||||
|
||||
log('Custom emojis updated, reloaded cache and picker data.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { importAccounts } from './accounts';
|
||||
import { importCustomEmoji } from './emoji';
|
||||
import { normalizeStatus } from './normalizer';
|
||||
import { importPolls } from './polls';
|
||||
import { fetchAccountsForCollectionPreview } from '@/mastodon/reducers/slices/collections';
|
||||
|
||||
export const STATUS_IMPORT = 'STATUS_IMPORT';
|
||||
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
|
||||
@@ -61,6 +62,7 @@ export function importFetchedStatuses(statuses, options = {}) {
|
||||
const normalStatuses = [];
|
||||
const polls = [];
|
||||
const filters = [];
|
||||
const collections = [];
|
||||
|
||||
function processStatus(status) {
|
||||
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), options));
|
||||
@@ -82,6 +84,10 @@ export function importFetchedStatuses(statuses, options = {}) {
|
||||
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
|
||||
}
|
||||
|
||||
if (status.tagged_collections.length) {
|
||||
status.tagged_collections.forEach(collection => pushUnique(collections, collection));
|
||||
}
|
||||
|
||||
if (status.card) {
|
||||
status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
|
||||
}
|
||||
@@ -97,5 +103,6 @@ export function importFetchedStatuses(statuses, options = {}) {
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(importStatuses(normalStatuses));
|
||||
dispatch(importFilters(filters));
|
||||
fetchAccountsForCollectionPreview(collections, dispatch);
|
||||
};
|
||||
}
|
||||
|
||||
187
app/javascript/mastodon/actions/importer/statuses.ts
Normal file
187
app/javascript/mastodon/actions/importer/statuses.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
|
||||
import type {
|
||||
ApiFilterResultJSON,
|
||||
ApiStatusJSON,
|
||||
} from '@/mastodon/api_types/statuses';
|
||||
import type {
|
||||
FilterResult,
|
||||
MediaAttachmentShape,
|
||||
StatusShape,
|
||||
} from '@/mastodon/models/status';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export function normalizeStatus(
|
||||
status: ApiStatusJSON,
|
||||
normalOldStatus?: StatusShape,
|
||||
{ bogusQuotePolicy = false, expandSpoilers = false } = {},
|
||||
) {
|
||||
const normalStatus: StatusShape = {
|
||||
hidden: normalOldStatus?.hidden ?? false,
|
||||
collapsed: normalOldStatus?.collapsed ?? false,
|
||||
content: '',
|
||||
contentHtml: '',
|
||||
muted: false,
|
||||
pinned: false,
|
||||
bookmarked: false,
|
||||
favourited: false,
|
||||
quote_approval: null,
|
||||
reblogged: false,
|
||||
|
||||
...status,
|
||||
|
||||
account: status.account.id,
|
||||
media_attachments: [],
|
||||
poll: status.poll?.id,
|
||||
reblog: status.reblog?.id,
|
||||
quote: undefined,
|
||||
filtered: [],
|
||||
application: {
|
||||
name: 'Web',
|
||||
...status.application,
|
||||
},
|
||||
};
|
||||
|
||||
if (bogusQuotePolicy) {
|
||||
normalStatus.quote_approval = null;
|
||||
}
|
||||
|
||||
if (status.quote?.quoted_status) {
|
||||
normalStatus.quote = {
|
||||
...status.quote,
|
||||
quoted_status: status.quote.quoted_status.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.card) {
|
||||
normalStatus.card = {
|
||||
...status.card,
|
||||
authors: status.card.authors.map((author) => ({
|
||||
name: author.name,
|
||||
url: author.url,
|
||||
accountId: author.account?.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (status.filtered) {
|
||||
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
||||
}
|
||||
|
||||
// Only calculate these values when status first encountered and
|
||||
// when the underlying values change. Otherwise keep the ones
|
||||
// already in the reducer
|
||||
if (
|
||||
normalOldStatus?.content === normalStatus.content &&
|
||||
normalOldStatus.spoiler_text === normalStatus.spoiler_text
|
||||
) {
|
||||
normalStatus.search_index = normalOldStatus.search_index;
|
||||
normalStatus.contentHtml = normalOldStatus.contentHtml;
|
||||
normalStatus.spoilerHtml = normalOldStatus.spoilerHtml;
|
||||
normalStatus.spoiler_text = normalOldStatus.spoiler_text;
|
||||
normalStatus.hidden = normalOldStatus.hidden;
|
||||
|
||||
if (normalOldStatus.translation) {
|
||||
normalStatus.translation = normalOldStatus.translation;
|
||||
}
|
||||
} else {
|
||||
// If the status has a CW but no contents, treat the CW as if it were the
|
||||
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
||||
if (
|
||||
normalStatus.spoiler_text &&
|
||||
!normalStatus.content &&
|
||||
!normalStatus.quote
|
||||
) {
|
||||
normalStatus.content = normalStatus.spoiler_text;
|
||||
normalStatus.spoiler_text = '';
|
||||
}
|
||||
|
||||
const spoilerText = normalStatus.spoiler_text ?? '';
|
||||
const searchContent = [spoilerText, status.content]
|
||||
.concat(
|
||||
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');
|
||||
|
||||
normalStatus.search_index = domParser.parseFromString(
|
||||
searchContent,
|
||||
'text/html',
|
||||
).documentElement.textContent;
|
||||
normalStatus.contentHtml = normalStatus.content;
|
||||
normalStatus.spoilerHtml = escapeTextContentForBrowser(spoilerText);
|
||||
normalStatus.hidden = expandSpoilers
|
||||
? false
|
||||
: spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (normalStatus.quote && normalStatus.contentHtml) {
|
||||
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
|
||||
}
|
||||
|
||||
if (
|
||||
normalStatus.url &&
|
||||
!(
|
||||
normalStatus.url.startsWith('http://') ||
|
||||
normalStatus.url.startsWith('https://')
|
||||
)
|
||||
) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
|
||||
normalStatus.url ??= normalStatus.uri;
|
||||
|
||||
normalStatus.media_attachments = status.media_attachments.map(
|
||||
(attachment) =>
|
||||
normalizeMediaAttachment(
|
||||
attachment,
|
||||
normalOldStatus?.media_attachments,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
normalStatus.quote_approval ??= normalOldStatus.quote_approval;
|
||||
}
|
||||
|
||||
return normalStatus;
|
||||
}
|
||||
|
||||
function normalizeMediaAttachment(
|
||||
attachment: ApiMediaAttachmentJSON,
|
||||
oldAttachments?: MediaAttachmentShape[],
|
||||
): MediaAttachmentShape {
|
||||
const { remote_url } = attachment;
|
||||
return {
|
||||
...attachment,
|
||||
remote_url:
|
||||
remote_url && /^https?:\/\//.test(remote_url) ? remote_url : null,
|
||||
translation: oldAttachments?.find(
|
||||
(oldAttachment) =>
|
||||
oldAttachment.id === attachment.id &&
|
||||
oldAttachment.description === attachment.description,
|
||||
)?.translation,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFilterResult(input: ApiFilterResultJSON): FilterResult {
|
||||
return {
|
||||
...input,
|
||||
filter: input.filter.id,
|
||||
};
|
||||
}
|
||||
|
||||
function stripQuoteFallback(text: string) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = text;
|
||||
|
||||
wrapper.querySelector('.quote-inline')?.remove();
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface OpenModalPayload {
|
||||
modalType: ModalType;
|
||||
modalProps: ModalProps;
|
||||
previousModalProps?: ModalProps;
|
||||
ignoreFocus?: boolean;
|
||||
}
|
||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
apiFetchNotificationGroups,
|
||||
} from 'mastodon/api/notifications';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import type {
|
||||
ApiNotificationGroupJSON,
|
||||
ApiNotificationJSON,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
createDataLoadingThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import { fetchAccountsForCollectionPreview } from '../reducers/slices/collections';
|
||||
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||
import { saveSettings } from './settings';
|
||||
@@ -70,6 +73,7 @@ function dispatchAssociatedRecords(
|
||||
) {
|
||||
const fetchedAccounts: ApiAccountJSON[] = [];
|
||||
const fetchedStatuses: ApiStatusJSON[] = [];
|
||||
const collections: ApiCollectionJSON[] = [];
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
if (notification.type === 'admin.report') {
|
||||
@@ -83,6 +87,10 @@ function dispatchAssociatedRecords(
|
||||
if ('status' in notification && notification.status) {
|
||||
fetchedStatuses.push(notification.status);
|
||||
}
|
||||
|
||||
if ('collection' in notification && notification.collection) {
|
||||
collections.push(notification.collection);
|
||||
}
|
||||
});
|
||||
|
||||
if (fetchedAccounts.length > 0)
|
||||
@@ -90,6 +98,9 @@ function dispatchAssociatedRecords(
|
||||
|
||||
if (fetchedStatuses.length > 0)
|
||||
dispatch(importFetchedStatuses(fetchedStatuses));
|
||||
|
||||
if (collections.length > 0)
|
||||
void fetchAccountsForCollectionPreview(collections, dispatch);
|
||||
}
|
||||
|
||||
function selectNotificationGroupedTypes(state: RootState) {
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
createAppAsyncThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import {
|
||||
fetchAccountsForCollectionPreview,
|
||||
importFetchedCollections,
|
||||
} from '../reducers/slices/collections';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
@@ -29,7 +34,7 @@ export const submitSearch = createDataLoadingThunk(
|
||||
limit: 11,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
async (data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
@@ -39,6 +44,11 @@ export const submitSearch = createDataLoadingThunk(
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.collections.length > 0) {
|
||||
dispatch(importFetchedCollections(data.collections));
|
||||
await fetchAccountsForCollectionPreview(data.collections, dispatch);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
@@ -60,7 +70,7 @@ export const expandSearch = createDataLoadingThunk(
|
||||
offset,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
async (data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
@@ -70,6 +80,11 @@ export const expandSearch = createDataLoadingThunk(
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
if (data.collections.length > 0) {
|
||||
dispatch(importFetchedCollections(data.collections));
|
||||
await fetchAccountsForCollectionPreview(data.collections, dispatch);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,16 +7,85 @@ export type MediaAttachmentType =
|
||||
| 'unknown'
|
||||
| 'audio';
|
||||
|
||||
export interface ApiMediaAttachmentJSON {
|
||||
interface BaseApiMediaAttachmentJSON {
|
||||
id: string;
|
||||
type: MediaAttachmentType;
|
||||
url: string;
|
||||
preview_url: string;
|
||||
remoteUrl: string;
|
||||
preview_remote_url: string;
|
||||
text_url: string;
|
||||
// TODO: how to define this?
|
||||
meta: unknown;
|
||||
remote_url?: string;
|
||||
preview_remote_url?: string;
|
||||
text_url?: string;
|
||||
description?: string;
|
||||
blurhash: string;
|
||||
}
|
||||
|
||||
interface ApiImageAttachmentJSON extends BaseApiMediaAttachmentJSON {
|
||||
type: 'image';
|
||||
meta: {
|
||||
original: ApiImageAttachmentMetaJSON;
|
||||
small: ApiImageAttachmentMetaJSON;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiAudioAttachmentJSON extends BaseApiMediaAttachmentJSON {
|
||||
type: 'audio';
|
||||
meta: {
|
||||
colors: ApiColorsAttachmentMetaJSON;
|
||||
original: ApiVideoAttachmentMetaJSON;
|
||||
small: ApiImageAttachmentMetaJSON;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiVideoAttachmentJSON extends BaseApiMediaAttachmentJSON {
|
||||
type: 'video';
|
||||
meta: {
|
||||
colors: ApiColorsAttachmentMetaJSON;
|
||||
original: ApiVideoAttachmentMetaJSON;
|
||||
small: ApiImageAttachmentMetaJSON;
|
||||
focus: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiGifvAttachmentJSON extends BaseApiMediaAttachmentJSON {
|
||||
type: 'gifv';
|
||||
meta: {
|
||||
original: ApiVideoAttachmentMetaJSON;
|
||||
small: ApiImageAttachmentMetaJSON;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiUnknownAttachmentJSON extends BaseApiMediaAttachmentJSON {
|
||||
type: 'unknown';
|
||||
meta: unknown;
|
||||
}
|
||||
|
||||
export type ApiMediaAttachmentJSON =
|
||||
| ApiImageAttachmentJSON
|
||||
| ApiAudioAttachmentJSON
|
||||
| ApiVideoAttachmentJSON
|
||||
| ApiGifvAttachmentJSON
|
||||
| ApiUnknownAttachmentJSON;
|
||||
|
||||
export interface ApiImageAttachmentMetaJSON {
|
||||
width: number;
|
||||
height: number;
|
||||
size: string;
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export interface ApiVideoAttachmentMetaJSON {
|
||||
width: number;
|
||||
height: number;
|
||||
frame_rate: string;
|
||||
duration: number;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
export interface ApiColorsAttachmentMetaJSON {
|
||||
background: string;
|
||||
foreground: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface NotificationPolicyJSON {
|
||||
for_new_accounts: NotificationPolicyValue;
|
||||
for_private_mentions: NotificationPolicyValue;
|
||||
for_limited_accounts: NotificationPolicyValue;
|
||||
for_bots: NotificationPolicyValue;
|
||||
summary: {
|
||||
pending_requests_count: number;
|
||||
pending_notifications_count: number;
|
||||
|
||||
@@ -90,22 +90,22 @@ interface ReportNotificationJSON extends BaseNotificationJSON {
|
||||
|
||||
interface AddedToCollectionNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||
type: 'added_to_collection';
|
||||
collection: ApiCollectionJSON;
|
||||
collection: ApiCollectionJSON | null;
|
||||
}
|
||||
|
||||
interface AddedToCollectionNotificationJSON extends BaseNotificationJSON {
|
||||
type: 'added_to_collection';
|
||||
collection: ApiCollectionJSON;
|
||||
collection: ApiCollectionJSON | null;
|
||||
}
|
||||
|
||||
interface CollectionUpdateNotificationGroupJSON extends BaseNotificationGroupJSON {
|
||||
type: 'collection_update';
|
||||
collection: ApiCollectionJSON;
|
||||
collection: ApiCollectionJSON | null;
|
||||
}
|
||||
|
||||
interface CollectionUpdateNotificationJSON extends BaseNotificationJSON {
|
||||
type: 'collection_update';
|
||||
collection: ApiCollectionJSON;
|
||||
collection: ApiCollectionJSON | null;
|
||||
}
|
||||
|
||||
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// See app/serializers/rest/status_serializer.rb
|
||||
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiCollectionJSON } from './collections';
|
||||
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||
import type { ApiMediaAttachmentJSON } from './media_attachments';
|
||||
import type { ApiPollJSON } from './polls';
|
||||
@@ -94,11 +95,11 @@ export interface ApiStatusJSON {
|
||||
url: string;
|
||||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favorites_count: number;
|
||||
favourites_count: number;
|
||||
quotes_count: number;
|
||||
edited_at?: string;
|
||||
|
||||
favorited?: boolean;
|
||||
favourited?: boolean;
|
||||
reblogged?: boolean;
|
||||
muted?: boolean;
|
||||
bookmarked?: boolean;
|
||||
@@ -116,6 +117,7 @@ export interface ApiStatusJSON {
|
||||
|
||||
tags: ApiTagJSON[];
|
||||
emojis: ApiCustomEmojiJSON[];
|
||||
tagged_collections: ApiCollectionJSON[];
|
||||
|
||||
card?: ApiPreviewCardJSON;
|
||||
poll?: ApiPollJSON;
|
||||
@@ -134,6 +136,19 @@ export interface ApiStatusSourceJSON {
|
||||
spoiler_text: string;
|
||||
}
|
||||
|
||||
export interface ApiStatusTranslationJSON {
|
||||
detected_source_language: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
contentHtml: string;
|
||||
spoilerHtml: string;
|
||||
spoiler_text: string;
|
||||
poll?: {
|
||||
id: string;
|
||||
options: { title: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export function isStatusVisibility(
|
||||
visibility: string,
|
||||
): visibility is StatusVisibility {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FollowsYouBadge } from '../badge';
|
||||
import { CopyButton } from '../copy_button';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { Icon } from '../icon';
|
||||
import { NavigationFocusTarget } from '../navigation_focus_target';
|
||||
|
||||
import { AccountBadges } from './badges';
|
||||
import classes from './styles.module.scss';
|
||||
@@ -56,9 +57,9 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
return (
|
||||
<div className={classes.nameWrapper}>
|
||||
<div className={classes.name}>
|
||||
<h1>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</h1>
|
||||
</NavigationFocusTarget>
|
||||
{relationship?.followed_by && <FollowsYouBadge />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const Alert: React.FC<{
|
||||
className='notification-bar__action'
|
||||
onClick={onActionClick}
|
||||
type='button'
|
||||
aria-hidden='true'
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from 'mastodon/models/alert';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { A11yLiveRegion } from './a11y_live_region';
|
||||
import { Alert } from './alert';
|
||||
|
||||
const formatIfNeeded = (
|
||||
@@ -75,12 +76,8 @@ const TimedAlert: React.FC<{
|
||||
export const AlertsController: React.FC = () => {
|
||||
const alerts = useAppSelector((state) => state.alerts);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='notification-list'>
|
||||
<A11yLiveRegion className='notification-list'>
|
||||
{alerts.map((alert, idx) => (
|
||||
<TimedAlert
|
||||
key={alert.key}
|
||||
@@ -88,6 +85,6 @@ export const AlertsController: React.FC = () => {
|
||||
dismissAfter={5000 + idx * 1000}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</A11yLiveRegion>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,15 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTitle: Story = {
|
||||
args: {
|
||||
title: '',
|
||||
primaryLabel: '',
|
||||
secondaryLabel: '',
|
||||
onClose: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoIcon: Story = {
|
||||
args: {
|
||||
icon: false,
|
||||
@@ -56,11 +65,11 @@ export const OnlyText: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// export const Subtle: Story = {
|
||||
// args: {
|
||||
// variant: 'subtle',
|
||||
// },
|
||||
// };
|
||||
export const Subtle: Story = {
|
||||
args: {
|
||||
variant: 'subtle',
|
||||
},
|
||||
};
|
||||
|
||||
export const Feature: Story = {
|
||||
args: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.3333;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -14,7 +15,7 @@
|
||||
border-radius: 9999px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: -2px;
|
||||
margin-block: -2px;
|
||||
}
|
||||
|
||||
.content,
|
||||
@@ -46,7 +47,7 @@
|
||||
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/identity_context';
|
||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
||||
|
||||
import { NavigationFocusTarget } from './navigation_focus_target';
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
export const messages = defineMessages({
|
||||
@@ -152,7 +153,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
||||
active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
const headingClassName = classNames('column-header', {
|
||||
active,
|
||||
});
|
||||
|
||||
@@ -272,48 +273,49 @@ export const ColumnHeader: React.FC<Props> = ({
|
||||
{!backButton && hasIcon && (
|
||||
<Icon id={icon} icon={iconComponent} className='column-header__icon' />
|
||||
)}
|
||||
{title}
|
||||
<span className='column-header__text'>{title}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const HeadingElement = hasTitle ? 'h1' : 'div';
|
||||
const titleClassNames = classNames('column-header__title', {
|
||||
'column-header__title--with-back-button': !!backButton,
|
||||
});
|
||||
|
||||
const component = (
|
||||
<div className={wrapperClassName}>
|
||||
<HeadingElement className={buttonClassName}>
|
||||
<div className={headingClassName}>
|
||||
{backButton}
|
||||
{hasTitle && (
|
||||
<>
|
||||
{backButton}
|
||||
|
||||
{onClick && (
|
||||
<NavigationFocusTarget
|
||||
as='h1'
|
||||
className='column-header__title-wrapper'
|
||||
>
|
||||
{onClick ? (
|
||||
<button
|
||||
onClick={handleTitleClick}
|
||||
className='column-header__title'
|
||||
className={titleClassNames}
|
||||
type='button'
|
||||
id={getColumnSkipLinkId(columnIndex)}
|
||||
>
|
||||
{titleContents}
|
||||
</button>
|
||||
)}
|
||||
{!onClick && (
|
||||
) : (
|
||||
<span
|
||||
className='column-header__title'
|
||||
className={titleClassNames}
|
||||
tabIndex={-1}
|
||||
id={getColumnSkipLinkId(columnIndex)}
|
||||
>
|
||||
{titleContents}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</NavigationFocusTarget>
|
||||
)}
|
||||
|
||||
{!hasTitle && backButton}
|
||||
|
||||
<div className='column-header__buttons'>
|
||||
{extraButton}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</HeadingElement>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={collapsibleClassName}
|
||||
|
||||
@@ -109,8 +109,8 @@ const hotkeyMatcherMap = {
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
openProfile: just('p'),
|
||||
moveDown: just('j'),
|
||||
moveUp: just('k'),
|
||||
moveDown: any('j', 'pagedown'),
|
||||
moveUp: any('k', 'pageup'),
|
||||
moveToTop: just('0'),
|
||||
toggleHidden: just('x'),
|
||||
toggleSensitive: just('h'),
|
||||
@@ -147,9 +147,15 @@ const hotkeyMatcherMap = {
|
||||
|
||||
type HotkeyName = keyof typeof hotkeyMatcherMap;
|
||||
|
||||
export type HandlerMap = Partial<
|
||||
Record<HotkeyName, (event: KeyboardEvent) => void>
|
||||
>;
|
||||
type HandlerFunction =
|
||||
// When a handler returns a boolean, it should indicate whether the
|
||||
// hotkey was handled (i.e. it resulted in an action).
|
||||
// If `false` is returned, `preventDefault` and `stopPropagation`
|
||||
// will not be called on the keyboard event, restoring the key's
|
||||
// native behaviour.
|
||||
((event: KeyboardEvent) => boolean) | ((event: KeyboardEvent) => void);
|
||||
|
||||
export type HandlerMap = Partial<Record<HotkeyName, HandlerFunction>>;
|
||||
|
||||
export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
const ref = useRef<T>(null);
|
||||
@@ -184,7 +190,7 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
const matchCandidates: {
|
||||
// A candidate will be have an undefined handler if it's matched,
|
||||
// but handled in a parent component rather than this one.
|
||||
handler: ((event: KeyboardEvent) => void) | undefined;
|
||||
handler: HandlerFunction | undefined;
|
||||
priority: number;
|
||||
}[] = [];
|
||||
|
||||
@@ -209,9 +215,11 @@ export function useHotkeys<T extends HTMLElement>(handlers: HandlerMap) {
|
||||
|
||||
const bestMatchingHandler = matchCandidates.at(0)?.handler;
|
||||
if (bestMatchingHandler) {
|
||||
bestMatchingHandler(event);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const wasHandled = bestMatchingHandler(event);
|
||||
if (wasHandled !== false) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Add last keypress to buffer
|
||||
|
||||
@@ -30,8 +30,8 @@ export const HoverCardController: React.FC = () => {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||
const [setMoveTimeout, cancelMoveTimeout] = useTimeout();
|
||||
const [setScrollTimeout] = useTimeout();
|
||||
const lastMouseMoveTime = useRef<number>(0);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancelEnterTimeout();
|
||||
@@ -50,7 +50,6 @@ export const HoverCardController: React.FC = () => {
|
||||
useEffect(() => {
|
||||
let isScrolling = false;
|
||||
let isUsingTouch = false;
|
||||
let isActiveMouseMovement = false;
|
||||
let currentAnchor: HTMLElement | null = null;
|
||||
let currentTitle: string | null = null;
|
||||
|
||||
@@ -84,31 +83,40 @@ export const HoverCardController: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out if we're scrolling, a touch is active,
|
||||
// or if there was no active mouse movement
|
||||
if (isScrolling || !isActiveMouseMovement || isUsingTouch) {
|
||||
return;
|
||||
}
|
||||
// This 0ms timeout is needed to push processing of this code
|
||||
// until after the mousemove event was run, in order to be able
|
||||
// to track the most recent value of lastMouseMoveTime.current
|
||||
setTimeout(() => {
|
||||
// Check if mouse moved within the active movement threshold
|
||||
const timeSinceLastMove = Date.now() - lastMouseMoveTime.current;
|
||||
const hasRecentMovement = timeSinceLastMove < activeMovementThreshold;
|
||||
|
||||
// We've entered an anchor
|
||||
if (isHoverCardAnchor(target)) {
|
||||
cancelLeaveTimeout();
|
||||
// Bail out if we're scrolling, a touch is active,
|
||||
// or if there was no active mouse movement
|
||||
if (isScrolling || !hasRecentMovement || isUsingTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAnchor?.removeAttribute('aria-describedby');
|
||||
currentAnchor = target;
|
||||
// We've entered an anchor
|
||||
if (isHoverCardAnchor(target)) {
|
||||
cancelLeaveTimeout();
|
||||
|
||||
currentTitle = target.getAttribute('title');
|
||||
target.removeAttribute('title');
|
||||
currentAnchor?.removeAttribute('aria-describedby');
|
||||
currentAnchor = target;
|
||||
|
||||
setEnterTimeout(() => {
|
||||
open(target);
|
||||
}, enterDelay);
|
||||
}
|
||||
currentTitle = target.getAttribute('title');
|
||||
target.removeAttribute('title');
|
||||
|
||||
// We've entered the hover card
|
||||
if (target === currentAnchor || target === cardRef.current) {
|
||||
cancelLeaveTimeout();
|
||||
}
|
||||
setEnterTimeout(() => {
|
||||
open(target);
|
||||
}, enterDelay);
|
||||
}
|
||||
|
||||
// We've entered the hover card
|
||||
if (target === currentAnchor || target === cardRef.current) {
|
||||
cancelLeaveTimeout();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: MouseEvent) => {
|
||||
@@ -149,18 +157,15 @@ export const HoverCardController: React.FC = () => {
|
||||
isUsingTouch = false;
|
||||
}
|
||||
|
||||
const hasMoved = Math.max(e.movementX, e.movementY) > 0;
|
||||
const hasMoved =
|
||||
Math.max(Math.abs(e.movementX), Math.abs(e.movementY)) > 0;
|
||||
|
||||
if (!hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
delayEnterTimeout(enterDelay);
|
||||
|
||||
cancelMoveTimeout();
|
||||
isActiveMouseMovement = true;
|
||||
setMoveTimeout(() => {
|
||||
isActiveMouseMovement = false;
|
||||
}, activeMovementThreshold);
|
||||
lastMouseMoveTime.current = Date.now();
|
||||
};
|
||||
|
||||
document.body.addEventListener('touchstart', handleTouchStart, {
|
||||
@@ -204,8 +209,6 @@ export const HoverCardController: React.FC = () => {
|
||||
setOpen,
|
||||
setAccountId,
|
||||
setAnchor,
|
||||
setMoveTimeout,
|
||||
cancelMoveTimeout,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
||||
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
|
||||
|
||||
class ModalRoot extends PureComponent {
|
||||
|
||||
@@ -21,7 +22,10 @@ class ModalRoot extends PureComponent {
|
||||
b: PropTypes.number,
|
||||
}),
|
||||
]),
|
||||
ignoreFocus: PropTypes.bool,
|
||||
ignoreFocus: PropTypes.oneOfType([
|
||||
PropTypes.bool,
|
||||
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
|
||||
]),
|
||||
...WithOptionalRouterPropTypes,
|
||||
};
|
||||
|
||||
@@ -118,7 +122,11 @@ class ModalRoot extends PureComponent {
|
||||
_ensureHistoryBuffer () {
|
||||
const { pathname, search, hash, state } = this.history.location;
|
||||
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
|
||||
this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey });
|
||||
this.history.push({ pathname, search, hash }, {
|
||||
...state,
|
||||
focusTarget: this.props.ignoreFocus !== IGNORE_FOCUS_ON_OPEN,
|
||||
mastodonModalKey: this._modalHistoryKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import type { MastodonLocation } from '../router';
|
||||
|
||||
export const FOCUS_TARGET = {
|
||||
POST: 'detailed-status',
|
||||
} as const;
|
||||
|
||||
export type FocusTarget =
|
||||
| boolean
|
||||
| (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET];
|
||||
|
||||
const FocusTargetContext =
|
||||
createContext<React.MutableRefObject<FocusTarget> | null>(null);
|
||||
|
||||
/**
|
||||
* `FocusTargetProvider` keeps track of whether focus should be
|
||||
* set after a navigation. By default, any navigation will set the
|
||||
* current value of `focusTargetRef` to `true`, which will cause
|
||||
* the `NavigationFocusTarget` component to focus itself when it mounts.
|
||||
*
|
||||
* To disable this behaviour for a navigation, the focus target can be
|
||||
* set to `false` using location state, for example:
|
||||
* ```
|
||||
* location.push(url, { focusTarget: false });
|
||||
* ```
|
||||
*
|
||||
* If the target page contains multiple `NavigationFocusTarget` components
|
||||
* (e.g. a main heading and a post that should be focused), give the more
|
||||
* specific `NavigationFocusTarget` instance a name, and pass the same name
|
||||
* via location state:
|
||||
* ```
|
||||
* location.push(url, { focusTarget: 'detailed-status' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const FocusTargetProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const focusTargetRef = useRef<FocusTarget>(false);
|
||||
const previousLocationRef = useRef<
|
||||
| (Pick<MastodonLocation, 'pathname' | 'search'> & {
|
||||
focusTarget?: FocusTarget;
|
||||
})
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const {
|
||||
pathname,
|
||||
search,
|
||||
state = {},
|
||||
} = useLocation<{ focusTarget?: FocusTarget } | undefined>();
|
||||
|
||||
const { focusTarget } = state;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// We never want to set focus on page load, so we keep
|
||||
// track of whether a manual navigation has occurred by comparing
|
||||
// our current with the previous location:
|
||||
const previous = previousLocationRef.current;
|
||||
|
||||
// Bail out on the first render, populate previousLocationRef
|
||||
if (previous === null) {
|
||||
previousLocationRef.current = { pathname, search, focusTarget };
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out if location hasn't changed
|
||||
if (
|
||||
previous.pathname === pathname &&
|
||||
previous.search === search &&
|
||||
previous.focusTarget === focusTarget
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Location has changed:
|
||||
// - Set focusTarget
|
||||
// – Store current location as previous
|
||||
// (We store `focusTarget` as `false` to allow overriding it.)
|
||||
previousLocationRef.current = { pathname, search, focusTarget: false };
|
||||
focusTargetRef.current = focusTarget ?? true;
|
||||
}, [pathname, search, focusTarget]);
|
||||
|
||||
return (
|
||||
<FocusTargetContext.Provider value={focusTargetRef}>
|
||||
{children}
|
||||
</FocusTargetContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useFocusOnNavigation(targetName?: string) {
|
||||
const focusTargetRef = useContext(FocusTargetContext);
|
||||
|
||||
return useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
const focusTarget = focusTargetRef?.current;
|
||||
|
||||
// Bail out if focusTarget was set to `false`
|
||||
if (!element || !focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusTarget === true || focusTarget === targetName) {
|
||||
setTimeout(() => {
|
||||
element.focus({ preventScroll: true });
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[focusTargetRef, targetName],
|
||||
);
|
||||
}
|
||||
|
||||
interface FocusTargetElementProps extends React.ComponentPropsWithoutRef<'h1'> {
|
||||
focusTargetName?: string;
|
||||
}
|
||||
|
||||
export const NavigationFocusTarget = polymorphicForwardRef<
|
||||
'h1',
|
||||
FocusTargetElementProps
|
||||
>(({ as: Component = 'h1', focusTargetName, children, ...otherProps }) => {
|
||||
const focusOnNavigation = useFocusOnNavigation(focusTargetName);
|
||||
|
||||
return (
|
||||
<Component ref={focusOnNavigation} tabIndex={-1} {...otherProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
@@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
|
||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||
import { isDevelopment } from 'mastodon/utils/environment';
|
||||
|
||||
import type { FocusTarget } from './navigation_focus_target';
|
||||
|
||||
interface MastodonLocationState {
|
||||
fromMastodon?: boolean;
|
||||
mastodonModalKey?: string;
|
||||
// Controls which element is focused after a navigation.
|
||||
// Set to `false` to prevent navigation focus.
|
||||
focusTarget?: FocusTarget;
|
||||
// Prevent the rightmost column in advanced UI from scrolling
|
||||
// into view on location changes
|
||||
preventMultiColumnAutoScroll?: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||
import { compareUrls } from '../utils/compare_urls';
|
||||
import { FOCUS_TARGET } from './navigation_focus_target';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
@@ -311,9 +312,9 @@ class Status extends ImmutablePureComponent {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else {
|
||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||
history.replace(path);
|
||||
history.replace(path, {focusTarget: FOCUS_TARGET.POST});
|
||||
} else {
|
||||
history.push(path);
|
||||
history.push(path, {focusTarget: FOCUS_TARGET.POST});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { hydrateStore } from 'mastodon/actions/store';
|
||||
import { connectUserStream } from 'mastodon/actions/streaming';
|
||||
import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||
import { Router } from 'mastodon/components/router';
|
||||
import UI from 'mastodon/features/ui';
|
||||
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
||||
@@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent {
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<ScrollContext>
|
||||
<Route path='/' component={UI} />
|
||||
<FocusTargetProvider>
|
||||
<Route path='/' component={UI} />
|
||||
</FocusTargetProvider>
|
||||
</ScrollContext>
|
||||
<BodyScrollLock />
|
||||
</Router>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiRuleJSON } from '@/mastodon/api_types/instance';
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import { Select } from '@/mastodon/components/form_fields';
|
||||
import type { RootState } from '@/mastodon/store';
|
||||
@@ -104,13 +105,13 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
</label>
|
||||
<Select onChange={handleLocaleChange} id='language-select'>
|
||||
<Select
|
||||
onChange={handleLocaleChange}
|
||||
id='language-select'
|
||||
value={selectedLocale}
|
||||
>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === selectedLocale}
|
||||
>
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
@@ -121,15 +122,10 @@ export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const selectRules = (state: RootState) => {
|
||||
const rules = state.server.server.item?.rules;
|
||||
|
||||
if (!rules) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rules;
|
||||
};
|
||||
const selectRules = createSelector(
|
||||
[(state: RootState) => state.server.server.item],
|
||||
(item) => item?.rules ?? [],
|
||||
);
|
||||
|
||||
const rulesSelector = createSelector(
|
||||
[selectRules, (_state, locale: string) => locale],
|
||||
@@ -142,18 +138,19 @@ const rulesSelector = createSelector(
|
||||
return rule;
|
||||
}
|
||||
|
||||
const translatedRule: ApiRuleJSON = { ...rule };
|
||||
const partialLocale = locale.split('-')[0];
|
||||
if (partialLocale && translations[partialLocale]) {
|
||||
rule.text = translations[partialLocale].text;
|
||||
rule.hint = translations[partialLocale].hint;
|
||||
translatedRule.text = translations[partialLocale].text;
|
||||
translatedRule.hint = translations[partialLocale].hint;
|
||||
}
|
||||
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
translatedRule.text = translations[locale].text;
|
||||
translatedRule.hint = translations[locale].hint;
|
||||
}
|
||||
|
||||
return rule;
|
||||
return translatedRule;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
|
||||
import { injectIntl } from '@/mastodon/components/intl';
|
||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
||||
@@ -91,7 +94,9 @@ class About extends PureComponent {
|
||||
srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')}
|
||||
className='about__header__hero'
|
||||
/>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
{isLoading ? <Skeleton width='10ch' /> : domain}
|
||||
</NavigationFocusTarget>
|
||||
<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>
|
||||
|
||||
@@ -168,7 +173,7 @@ class About extends PureComponent {
|
||||
<LinkFooter context='about' />
|
||||
|
||||
<div className='about__footer'>
|
||||
<p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
|
||||
<p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon GmbH.' /></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||
modalProps: { fieldKey: id },
|
||||
ignoreFocus: true,
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
@@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
|
||||
);
|
||||
|
||||
const handleOpenModal = useCallback(
|
||||
(type: ModalType, props?: Record<string, unknown>) => {
|
||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||
(
|
||||
type: ModalType,
|
||||
{
|
||||
modalProps = {},
|
||||
ignoreFocus = false,
|
||||
}: { modalProps?: Record<string, unknown>; ignoreFocus?: boolean } = {},
|
||||
) => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: type,
|
||||
modalProps,
|
||||
ignoreFocus,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const handleNameEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
||||
handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true });
|
||||
}, [handleOpenModal]);
|
||||
const handleBioEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true });
|
||||
}, [handleOpenModal]);
|
||||
const handleCustomFieldAdd = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
||||
|
||||
@@ -140,7 +140,7 @@ export const EditFieldModal = forwardRef<
|
||||
|
||||
const customEmojis = useCustomEmojis();
|
||||
const customEmojiCodes = useMemo(
|
||||
() => Object.keys(customEmojis ?? {}),
|
||||
() => Object.keys(customEmojis),
|
||||
[customEmojis],
|
||||
);
|
||||
const checkField = useCallback(
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
|
||||
import { CollectionListItem } from '../collections/components/collection_list_item';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_account';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { Subheading, SubheadingLink } from './components/subheading';
|
||||
@@ -41,6 +41,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
const account = useAccount(accountId);
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
const isOwnProfile = accountId === me;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -69,10 +70,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
|
||||
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
|
||||
collections,
|
||||
(item) =>
|
||||
item.discoverable && !!item.item_count
|
||||
? 'listedCollections'
|
||||
: 'unlistedCollections',
|
||||
(item) => (item.discoverable ? 'listedCollections' : 'unlistedCollections'),
|
||||
);
|
||||
|
||||
const renderListItem = useCallback(
|
||||
@@ -166,7 +164,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h2>
|
||||
{accountId === me && (
|
||||
{isOwnProfile && (
|
||||
<SubheadingLink to='/collections/new' icon={AddIcon}>
|
||||
<FormattedMessage
|
||||
id='account.featured.new_collection'
|
||||
@@ -179,7 +177,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
<ItemList>
|
||||
<TruncatedListItems
|
||||
visibleItems={listedCollections}
|
||||
truncatedItems={unlistedCollections}
|
||||
truncatedItems={isOwnProfile ? unlistedCollections : []}
|
||||
toggleButton={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ModalShellActions,
|
||||
ModalShellBody,
|
||||
} from '@/mastodon/components/modal_shell';
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
|
||||
@@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShellBody>
|
||||
<EmojiHTML
|
||||
as='h2'
|
||||
htmlString={field.name_emojified}
|
||||
onElement={handleLabelElement}
|
||||
className={classes.fieldName}
|
||||
/>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={field.name_emojified}
|
||||
onElement={handleLabelElement}
|
||||
className={classes.fieldName}
|
||||
/>
|
||||
</NavigationFocusTarget>
|
||||
<EmojiHTML
|
||||
as='p'
|
||||
htmlString={field.value_emojified}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// app/javascript/mastodon/features/alt_text_modal/__tests__/index-test.tsx
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { List, Map } from 'immutable';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||
import { Router } from '@/mastodon/components/router';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { AltTextModal } from '../index';
|
||||
|
||||
vi.mock('mastodon/store', () => ({
|
||||
useAppSelector: vi.fn(),
|
||||
useAppDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
describe('<AltTextModal />', () => {
|
||||
const mediaId = '123';
|
||||
const handleClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<Router>
|
||||
<FocusTargetProvider>
|
||||
<IntlProvider locale='en' messages={{}}>
|
||||
<AltTextModal mediaId={mediaId} onClose={handleClose} />
|
||||
</IntlProvider>
|
||||
</FocusTargetProvider>
|
||||
</Router>,
|
||||
);
|
||||
};
|
||||
|
||||
it('renders thumbnail upload button when video is unattached', () => {
|
||||
vi.mocked(useAppSelector).mockImplementation(
|
||||
(selector: (state: RootState) => unknown) => {
|
||||
const mockState = {
|
||||
compose: Map({
|
||||
language: 'en',
|
||||
media_attachments: List([
|
||||
Map({
|
||||
id: mediaId,
|
||||
type: 'video',
|
||||
unattached: true,
|
||||
meta: Map({ focus: Map({ x: 0, y: 0 }) }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
accounts: Map(),
|
||||
} as unknown as RootState;
|
||||
|
||||
return selector(mockState);
|
||||
},
|
||||
);
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
const uploadInput = container.querySelector('#upload-modal__thumbnail');
|
||||
expect(uploadInput).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides thumbnail upload button when video is attached', () => {
|
||||
vi.mocked(useAppSelector).mockImplementation(
|
||||
(selector: (state: RootState) => unknown) => {
|
||||
const mockState = {
|
||||
compose: Map({
|
||||
language: 'en',
|
||||
media_attachments: List([
|
||||
Map({
|
||||
id: mediaId,
|
||||
type: 'video',
|
||||
unattached: false,
|
||||
meta: Map({ focus: Map({ x: 0, y: 0 }) }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
accounts: Map(),
|
||||
} as unknown as RootState;
|
||||
|
||||
return selector(mockState);
|
||||
},
|
||||
);
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
const uploadInput = container.querySelector('#upload-modal__thumbnail');
|
||||
expect(uploadInput).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { changeUploadCompose } from 'mastodon/actions/compose_typed';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||
@@ -54,7 +55,8 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_LENGTH = 1500;
|
||||
// TODO: use `description_limit` from the `/api/v2/instance` response
|
||||
const MAX_LENGTH = 10000;
|
||||
|
||||
type FocalPoint = [number, number];
|
||||
|
||||
@@ -283,6 +285,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
);
|
||||
const type = media?.get('type') as string;
|
||||
const valid = length(description) <= MAX_LENGTH;
|
||||
const unattached = media?.get('unattached') as boolean | undefined;
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -410,12 +413,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className='dialog-modal__header__title'>
|
||||
<NavigationFocusTarget
|
||||
as='h1'
|
||||
className='dialog-modal__header__title'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='alt_text_modal.add_alt_text'
|
||||
defaultMessage='Add alt text'
|
||||
/>
|
||||
</span>
|
||||
</NavigationFocusTarget>
|
||||
|
||||
<Button secondary onClick={onClose}>
|
||||
<FormattedMessage
|
||||
@@ -433,7 +439,8 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||
onPositionChange={handlePositionChange}
|
||||
/>
|
||||
|
||||
{(type === 'audio' || type === 'video') && (
|
||||
{/* This button is hidden for attached audio/video files, as they are already posted */}
|
||||
{(type === 'audio' || type === 'video') && unattached && (
|
||||
<UploadButton
|
||||
onSelectFile={handleThumbnailChange}
|
||||
mimeTypes='image/jpeg,image/png,image/gif,image/heic,image/heif,image/webp,image/avif'
|
||||
|
||||
@@ -10,6 +10,7 @@ import classNames from 'classnames/bind';
|
||||
import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { getReport } from '@/mastodon/reducers/slices/annual_report';
|
||||
import {
|
||||
createAppSelector,
|
||||
@@ -83,7 +84,9 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
||||
return (
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<div className={styles.header}>
|
||||
<h1>Wrapstodon {report.year}</h1>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
Wrapstodon {report.year}
|
||||
</NavigationFocusTarget>
|
||||
{account && <p>@{account.acct}</p>}
|
||||
{context === 'modal' && (
|
||||
<IconButton
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
||||
@@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
||||
return (
|
||||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
<FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' />
|
||||
</NavigationFocusTarget>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='closed_registrations_modal.preamble'
|
||||
@@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
||||
|
||||
<div className='interaction-modal__choices'>
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
|
||||
<h2><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h2>
|
||||
{closedRegistrationsMessage}
|
||||
</div>
|
||||
|
||||
<div className='interaction-modal__choices__choice'>
|
||||
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
|
||||
<h2><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h2>
|
||||
<p className='prose'>
|
||||
<FormattedMessage
|
||||
id='closed_registrations.other_server_instructions'
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useCallback, useId, useState } from 'react';
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import type { ApiCollectionJSON } from '@/mastodon/api_types/collections';
|
||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import {
|
||||
@@ -15,7 +17,10 @@ import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
import {
|
||||
NewCollectionButton,
|
||||
useCollectionsCreatedBy,
|
||||
} from '../collections/overview/created_by_account';
|
||||
|
||||
import { CollectionToggle } from './collection_toggle';
|
||||
|
||||
@@ -114,13 +119,17 @@ export const CollectionAdder: React.FC<{
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<span className='dialog-modal__header__title' id={titleId}>
|
||||
<NavigationFocusTarget
|
||||
as='h1'
|
||||
id={titleId}
|
||||
className='dialog-modal__header__title'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.add_to_collection'
|
||||
defaultMessage='Add {name} to collections'
|
||||
values={{ name: <strong>@{account?.acct}</strong> }}
|
||||
/>
|
||||
</span>
|
||||
</NavigationFocusTarget>
|
||||
</div>
|
||||
|
||||
<div className='dialog-modal__content'>
|
||||
@@ -131,6 +140,23 @@ export const CollectionAdder: React.FC<{
|
||||
>
|
||||
{status === 'loading' || !account ? (
|
||||
<LoadingIndicator />
|
||||
) : collections.length === 0 ? (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections_self'
|
||||
defaultMessage='You have not created any collections yet.'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NewCollectionButton onClick={onClose} />
|
||||
</EmptyState>
|
||||
) : (
|
||||
collections.map((item) => (
|
||||
<ListItem key={item.id} collection={item} account={account} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
|
||||
@@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShellBody>
|
||||
<h1 className={classes.heading}>
|
||||
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||
{isNew ? (
|
||||
<FormattedMessage
|
||||
id='collection.share_modal.title_new'
|
||||
@@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
|
||||
defaultMessage='Share collection'
|
||||
/>
|
||||
)}
|
||||
</h1>
|
||||
</NavigationFocusTarget>
|
||||
|
||||
<IconButton
|
||||
title={intl.formatMessage({
|
||||
|
||||
@@ -19,7 +19,7 @@ const messages = defineMessages({
|
||||
revokeCollectionInclusionMessage: {
|
||||
id: 'confirmations.revoke_collection_inclusion.message',
|
||||
defaultMessage:
|
||||
"This action is permanent, and the curator won't be able to re-add you to the collection later on.",
|
||||
"The curator won't be able to re-add you to this collection for 24 hours. To prevent them from adding you to collections permanently, you can block them.",
|
||||
},
|
||||
revokeCollectionInclusionConfirm: {
|
||||
id: 'confirmations.revoke_collection_inclusion.confirm',
|
||||
|
||||
@@ -208,6 +208,12 @@ export const CollectionAccounts: React.FC<{
|
||||
const hasItems = editorItems.length > 0;
|
||||
const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT;
|
||||
|
||||
const wasAccountAdded = useCallback(
|
||||
(account: ApiMutedAccountJSON) =>
|
||||
!!editorItems.find((item) => item.account_id === account.id),
|
||||
[editorItems],
|
||||
);
|
||||
|
||||
const {
|
||||
accounts: suggestedAccounts,
|
||||
isLoading: isLoadingSuggestions,
|
||||
@@ -217,8 +223,7 @@ export const CollectionAccounts: React.FC<{
|
||||
withRelationships: true,
|
||||
withDefaultFollows: searchValue === '',
|
||||
// Don't suggest accounts that were already added
|
||||
filterResults: (account) =>
|
||||
!editorItems.find((item) => item.account_id === account.id),
|
||||
filterResults: (account) => !wasAccountAdded(account),
|
||||
});
|
||||
|
||||
const relationships = useAppSelector((state) => state.relationships);
|
||||
@@ -256,23 +261,25 @@ export const CollectionAccounts: React.FC<{
|
||||
|
||||
const addAccountItem = useCallback(
|
||||
(item: ApiMutedAccountJSON) => {
|
||||
dispatch(
|
||||
updateCollectionEditorField({
|
||||
field: 'items',
|
||||
value: [
|
||||
...editorItems,
|
||||
{
|
||||
account_id: item.id,
|
||||
state:
|
||||
item.feature_approval.current_user === 'manual'
|
||||
? 'pending'
|
||||
: 'accepted',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (!wasAccountAdded(item)) {
|
||||
dispatch(
|
||||
updateCollectionEditorField({
|
||||
field: 'items',
|
||||
value: [
|
||||
...editorItems,
|
||||
{
|
||||
account_id: item.id,
|
||||
state:
|
||||
item.feature_approval.current_user === 'manual'
|
||||
? 'pending'
|
||||
: 'accepted',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[editorItems, dispatch],
|
||||
[editorItems, wasAccountAdded, dispatch],
|
||||
);
|
||||
|
||||
const instantRemoveAccountItem = useCallback(
|
||||
@@ -299,13 +306,13 @@ export const CollectionAccounts: React.FC<{
|
||||
|
||||
const instantAddAccountItem = useCallback(
|
||||
(item: ApiMutedAccountJSON) => {
|
||||
if (id) {
|
||||
if (id && !wasAccountAdded(item)) {
|
||||
void dispatch(
|
||||
addCollectionItem({ collectionId: id, accountId: item.id }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, id],
|
||||
[dispatch, id, wasAccountAdded],
|
||||
);
|
||||
|
||||
const handleRemoveAccountItem = useCallback(
|
||||
|
||||
@@ -13,20 +13,21 @@ import {
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { initialState } from '@/mastodon/initial_state';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { initialState } from 'mastodon/initial_state';
|
||||
import {
|
||||
collectionEditorActions,
|
||||
fetchCollection,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { useCollectionsCreatedBy } from '../overview/created_by_you';
|
||||
import { useCollectionsCreatedBy } from '../overview/created_by_account';
|
||||
|
||||
import { CollectionAccounts } from './accounts';
|
||||
import { CollectionDetails } from './details';
|
||||
@@ -75,7 +76,7 @@ export const CollectionEditorPage: React.FC<{
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountId = useCurrentAccountId();
|
||||
const { accountId, signedIn } = useIdentity();
|
||||
const { id = null } = useParams<{ id?: string }>();
|
||||
const { path } = useRouteMatch();
|
||||
const collection = useAppSelector((state) =>
|
||||
@@ -94,13 +95,13 @@ export const CollectionEditorPage: React.FC<{
|
||||
(!isEditMode && collectionListStatus === 'loading');
|
||||
|
||||
const canCreateMoreCollections =
|
||||
isEditMode || collectionList.length < userCollectionLimit;
|
||||
signedIn && (isEditMode || collectionList.length < userCollectionLimit);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
if (id && signedIn) {
|
||||
void dispatch(fetchCollection({ collectionId: id }));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
}, [dispatch, id, signedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id !== editorStateId) {
|
||||
@@ -129,6 +130,8 @@ export const CollectionEditorPage: React.FC<{
|
||||
<div className='scrollable'>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : !signedIn ? (
|
||||
<NotSignedInIndicator />
|
||||
) : canCreateMoreCollections ? (
|
||||
<Switch>
|
||||
<Route
|
||||
|
||||
@@ -4,15 +4,16 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { TabLink, TabList } from '@/mastodon/components/tab_list';
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
||||
import { Scrollable } from 'mastodon/components/scrollable_list/components';
|
||||
import { TabLink, TabList } from 'mastodon/components/tab_list';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
|
||||
|
||||
import { CollectionsCreatedByYou } from './overview/created_by_you';
|
||||
import { CollectionsCreatedByAccount } from './overview/created_by_account';
|
||||
import { CollectionsFeaturingYou } from './overview/featuring_you';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
@@ -71,7 +72,9 @@ export const Collections: React.FC<{
|
||||
|
||||
<Scrollable>
|
||||
<header className={classes.header}>
|
||||
<h1 className={classes.heading}>{pageTitleHtml}</h1>
|
||||
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||
{pageTitleHtml}
|
||||
</NavigationFocusTarget>
|
||||
<TabList plain>
|
||||
<TabLink exact to={`/@${account?.acct}/collections`}>
|
||||
{intl.formatMessage(createdByTabMessage, {
|
||||
@@ -89,7 +92,7 @@ export const Collections: React.FC<{
|
||||
</TabList>
|
||||
</header>
|
||||
<Switch>
|
||||
<Route exact path={path} component={CollectionsCreatedByYou} />
|
||||
<Route exact path={path} component={CollectionsCreatedByAccount} />
|
||||
<Route
|
||||
exact
|
||||
path={`${path}/featuring-you`}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { EmptyState } from 'mastodon/components/empty_state';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ItemList } from 'mastodon/components/scrollable_list/components';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useAccountId, useCurrentAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import {
|
||||
fetchCollectionsCreatedByAccount,
|
||||
@@ -24,8 +26,14 @@ import {
|
||||
} from '../editor';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const CreateButton: React.FC = () => (
|
||||
<Link to='/collections/new' className='button button--compact'>
|
||||
export const NewCollectionButton: React.FC<{ onClick?: () => void }> = ({
|
||||
onClick,
|
||||
}) => (
|
||||
<Link
|
||||
to='/collections/new'
|
||||
className='button button--compact'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
<FormattedMessage {...editorMessages.newCollection} />
|
||||
</Link>
|
||||
@@ -58,9 +66,10 @@ export function useCollectionsCreatedBy(accountId: string | null | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
export const CollectionsCreatedByYou: React.FC = () => {
|
||||
export const CollectionsCreatedByAccount: React.FC = () => {
|
||||
const me = useCurrentAccountId();
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const { collections, status } = useCollectionsCreatedBy(accountId);
|
||||
|
||||
@@ -78,24 +87,40 @@ export const CollectionsCreatedByYou: React.FC = () => {
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts'
|
||||
defaultMessage='Showcase your favorite accounts'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CreateButton />
|
||||
</EmptyState>
|
||||
);
|
||||
if (isOwnCollectionPage) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts'
|
||||
defaultMessage='Showcase your favorite accounts'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NewCollectionButton />
|
||||
</EmptyState>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.collections'
|
||||
defaultMessage='{acct} has not created any collections yet.'
|
||||
values={{
|
||||
acct: <DisplayName variant='simple' account={account} />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,10 +135,10 @@ export const CollectionsCreatedByYou: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
{showCreateButton && <CreateButton />}
|
||||
{showCreateButton && <NewCollectionButton />}
|
||||
</div>
|
||||
<ItemList>
|
||||
{!canCreateMoreCollections && (
|
||||
{isOwnCollectionPage && !canCreateMoreCollections && (
|
||||
<MaxCollectionsCallout className={classes.maxCollectionsError} />
|
||||
)}
|
||||
{collections.map((item, index) => (
|
||||
@@ -16,7 +16,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { CollectionListItem } from '../components/collection_list_item';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CollectionListError } from './created_by_you';
|
||||
import { CollectionListError } from './created_by_account';
|
||||
|
||||
function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -220,7 +220,9 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||
this.textareaRef.current.focus();
|
||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||
if (this.props.spoiler) {
|
||||
const mediaJustAdded = this.props.anyMedia && !prevProps.anyMedia;
|
||||
|
||||
if (this.props.spoiler && !mediaJustAdded) {
|
||||
this.spoilerText.input.focus();
|
||||
} else if (prevProps.spoiler) {
|
||||
this.textareaRef.current.focus();
|
||||
@@ -314,8 +316,8 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
className='compose-form__input'
|
||||
/>
|
||||
|
||||
<UploadForm />
|
||||
<PollForm />
|
||||
<UploadForm />
|
||||
<ComposeQuotedStatus />
|
||||
|
||||
<div className='compose-form__footer'>
|
||||
|
||||
@@ -323,10 +323,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
void EmojiMart.loadCustomEmojiData().then(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false, active: false });
|
||||
});
|
||||
|
||||
@@ -4,12 +4,10 @@ import { addPoll, removePoll } from '../../../actions/compose';
|
||||
import PollButton from '../components/poll_button';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const readyAttachmentsSize = state.compose.get('media_attachments').size ?? 0;
|
||||
const hasAttachments = readyAttachmentsSize > 0 || !!state.compose.get('is_uploading');
|
||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||
|
||||
return ({
|
||||
disabled: hasAttachments || hasQuote,
|
||||
disabled: hasQuote,
|
||||
active: state.getIn(['compose', 'poll']) !== null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { uploadCompose } from '../../../actions/compose';
|
||||
import UploadButton from '../components/upload_button';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const isPoll = state.getIn(['compose', 'poll']) !== null;
|
||||
const isUploading = state.getIn(['compose', 'is_uploading']);
|
||||
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
||||
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
||||
@@ -14,7 +13,7 @@ const mapStateToProps = state => {
|
||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||
|
||||
return {
|
||||
disabled: isPoll || isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
||||
disabled: isUploading || isOverLimit || hasVideoOrAudio || hasQuote,
|
||||
resetFileKey: state.getIn(['compose', 'resetFileKey']),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -193,7 +193,11 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
/>
|
||||
|
||||
<div className='scrollable'>
|
||||
<ComposeFormContainer />
|
||||
<ComposeFormContainer
|
||||
// This is fine on this single-purpose view
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { TabLink, TabList } from 'mastodon/components/tab_list';
|
||||
@@ -40,7 +41,9 @@ export const CustomHomepage: React.FC = () => {
|
||||
/>
|
||||
|
||||
<div className={classes.topSection}>
|
||||
<h1>{server.item?.domain}</h1>
|
||||
<NavigationFocusTarget as='h1'>
|
||||
{server.item?.domain}
|
||||
</NavigationFocusTarget>
|
||||
<p>{server.item?.description}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
search,
|
||||
loadEmojiByHexcode,
|
||||
testClear,
|
||||
testGet,
|
||||
@@ -22,11 +23,117 @@ function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
|
||||
}
|
||||
|
||||
describe('emoji database', () => {
|
||||
beforeEach(async () => {
|
||||
await testGet(); // Loads the database schema.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testClear();
|
||||
indexedDB = new IDBFactory();
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await putEmojiData([], 'en');
|
||||
});
|
||||
|
||||
test('test no query tokens', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await expect(search({ query: ' ', locale: 'en' })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test('unicode results', async () => {
|
||||
await putEmojiData(
|
||||
[
|
||||
rawEmojiFactory({
|
||||
hexcode: 'unicode_hex',
|
||||
label: 'Party Popper',
|
||||
shortcodes: ['party_popper'],
|
||||
unicode: '🎉',
|
||||
}),
|
||||
],
|
||||
'en',
|
||||
);
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'unicode_hex',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom results', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [customEmojiFactory({ shortcode: 'party_custom' })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'party', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_custom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('shortcode results', async () => {
|
||||
await putEmojiData([rawEmojiFactory()], 'en');
|
||||
await putLegacyShortcodes({
|
||||
test: ['legacy_smile'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'legacy', locale: 'en' }),
|
||||
).resolves.toContainEqual(
|
||||
expect.objectContaining({
|
||||
hexcode: 'test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('full custom emoji search', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'arrow' }),
|
||||
customEmojiFactory({ shortcode: 'party_parrot' }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await search({ query: 'arro', locale: 'en' });
|
||||
expect(result).toContainEqual(
|
||||
// Test for ordinary IDB search
|
||||
expect.objectContaining({
|
||||
shortcode: 'arrow',
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
// Test for manual iteration search
|
||||
expect.objectContaining({
|
||||
shortcode: 'party_parrot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('limit test', async () => {
|
||||
await putCustomEmojiData({
|
||||
emojis: [
|
||||
customEmojiFactory({ shortcode: 'limit' }),
|
||||
customEmojiFactory({ shortcode: 'limit_extra' }),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
search({ query: 'limit', locale: 'en', limit: 1 }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
shortcode: 'limit',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putEmojiData', () => {
|
||||
test('adds to loaded locales', async () => {
|
||||
const { loadedLocales } = await testGet();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
transformCustomEmojiData,
|
||||
transformEmojiData,
|
||||
} from './normalize';
|
||||
import type { AnyEmojiData, CacheKey } from './types';
|
||||
import type { AnyEmojiData, CacheKey, CustomEmojiData } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const loadedLocales = new Set<Locale>();
|
||||
@@ -78,6 +78,8 @@ export async function search({
|
||||
// Create an array of emoji results
|
||||
const db = await loadDB();
|
||||
const resultArrays: ScoreMap[] = [];
|
||||
const existingCustomShortcodes = new Set<string>();
|
||||
|
||||
for (let i = 0; i < queryTokens.length; i++) {
|
||||
const token = queryTokens[i];
|
||||
if (!token) continue;
|
||||
@@ -96,6 +98,7 @@ export async function search({
|
||||
],
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
|
||||
for (const emoji of unicodeResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
@@ -103,11 +106,13 @@ export async function search({
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const emoji of customResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
existingCustomShortcodes.add(emoji.shortcode);
|
||||
resultMap.set(emoji.shortcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
@@ -119,7 +124,14 @@ export async function search({
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
// Score the emoji with the legacy shortcode, even though it's not part of the emoji.
|
||||
const score = getScoreForEmoji(
|
||||
{
|
||||
...emoji,
|
||||
shortcodes: [...shortcodeResult.shortcodes, ...emoji.shortcodes],
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -143,7 +155,26 @@ export async function search({
|
||||
return intersection;
|
||||
})
|
||||
.values(),
|
||||
).toSorted((a, b) => a.score - b.score);
|
||||
);
|
||||
|
||||
// If there are no results, try a cursor-based custom emoji search instead.
|
||||
if (results.length === 0 || results.length < limit) {
|
||||
const customEmojisFound = await fullCustomSearch(
|
||||
query,
|
||||
existingCustomShortcodes,
|
||||
);
|
||||
if (customEmojisFound.length > 0) {
|
||||
log(
|
||||
'cursor search found %d results for "%s"',
|
||||
customEmojisFound.length,
|
||||
query,
|
||||
);
|
||||
results.push(...customEmojisFound);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score, descending.
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
|
||||
const time = performance.measure('emoji-search-end', 'emoji-search-start');
|
||||
log(
|
||||
@@ -159,14 +190,26 @@ export async function search({
|
||||
return results;
|
||||
}
|
||||
|
||||
function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
function getScoreForEmoji(
|
||||
emoji: AnyEmojiData,
|
||||
query: string,
|
||||
checkTokens = true,
|
||||
) {
|
||||
const id = 'shortcode' in emoji ? emoji.shortcode : emoji.label;
|
||||
if (id === query) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
for (const token of [id, ...emoji.tokens]) {
|
||||
const searchTokens = [id];
|
||||
if (checkTokens) {
|
||||
// Check shortcodes before tokens as they are more important.
|
||||
if ('shortcodes' in emoji) {
|
||||
searchTokens.push(...emoji.shortcodes);
|
||||
}
|
||||
searchTokens.push(...emoji.tokens);
|
||||
}
|
||||
for (const token of searchTokens) {
|
||||
const tokenIndex = token.indexOf(query);
|
||||
if (tokenIndex !== -1) {
|
||||
return index + tokenIndex / token.length;
|
||||
@@ -177,6 +220,51 @@ function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fullCustomSearch(query: string, existing = new Set<string>()) {
|
||||
const db = await loadDB();
|
||||
const trx = db.transaction('custom', 'readonly');
|
||||
const foundEmojis = new Set<string>();
|
||||
|
||||
// First iterate over chunks of 1,000 custom emoji keys and find any matches.
|
||||
const chunkSize = 1_000;
|
||||
let lastKey: string | null = null;
|
||||
let keys: string[] = [];
|
||||
do {
|
||||
const keyRange = lastKey ? IDBKeyRange.lowerBound(lastKey, true) : null;
|
||||
keys = await trx.store.getAllKeys(keyRange, chunkSize);
|
||||
|
||||
if (keys.length === 0) {
|
||||
break;
|
||||
}
|
||||
log('cursor search got batch of %d emojis', keys.length);
|
||||
lastKey = keys.at(-1) ?? null;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!foundEmojis.has(key) && !existing.has(key) && key.includes(query)) {
|
||||
foundEmojis.add(key);
|
||||
}
|
||||
}
|
||||
} while (keys.length === chunkSize);
|
||||
|
||||
// Next get the full emojis for all matches.
|
||||
const emojis = await Promise.all(
|
||||
foundEmojis.keys().map((key) => trx.store.get(key)),
|
||||
);
|
||||
const results: (CustomEmojiData & { score: number })[] = [];
|
||||
for (const emoji of emojis) {
|
||||
if (emoji) {
|
||||
const score = getScoreForEmoji(emoji, query, false);
|
||||
if (score && score > 0) {
|
||||
results.push({
|
||||
score,
|
||||
...emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function putEmojiData(emojis: CompactEmoji[], locale: Locale) {
|
||||
loadedLocales.add(locale);
|
||||
const db = await loadDB();
|
||||
@@ -246,6 +334,13 @@ export async function clearCache(key: CacheKey) {
|
||||
log('Cleared cache for %s', key);
|
||||
}
|
||||
|
||||
export async function resetDatabase() {
|
||||
const db = await loadDB();
|
||||
const storeNames = [...db.objectStoreNames];
|
||||
await Promise.all(storeNames.map((storeName) => db.clear(storeName)));
|
||||
log(storeNames, 'Reset emoji database stores:');
|
||||
}
|
||||
|
||||
export async function loadEmojiByHexcode(
|
||||
hexcode: string,
|
||||
localeString: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
StoreNames,
|
||||
} from 'idb';
|
||||
|
||||
import { resetDatabase } from './database';
|
||||
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
@@ -57,7 +58,7 @@ type Transaction<Mode extends IDBTransactionMode = 'versionchange'> =
|
||||
|
||||
export type Database = IDBPDatabase<EmojiDB>;
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
const SCHEMA_VERSION = 4;
|
||||
|
||||
export async function openEmojiDB() {
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
@@ -98,6 +99,8 @@ export async function openEmojiDB() {
|
||||
});
|
||||
deleteOldIndexes(shortcodeTable, ['hexcode']);
|
||||
|
||||
void resetDatabase();
|
||||
|
||||
log(
|
||||
'Upgraded emoji database from version %d to %d',
|
||||
oldVersion,
|
||||
|
||||
@@ -13,8 +13,6 @@ import { usePickerEmojis } from './picker';
|
||||
|
||||
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_16_0.png`;
|
||||
|
||||
export { fetchCustomEmojiData as loadCustomEmojiData } from './picker';
|
||||
|
||||
export const Picker: FC<PickerProps> = ({
|
||||
set = 'twitter',
|
||||
sheetSize = 32,
|
||||
@@ -24,17 +22,13 @@ export const Picker: FC<PickerProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { mode } = useEmojiAppState();
|
||||
const { customCategories, customEmojis } = usePickerEmojis();
|
||||
|
||||
if (!customEmojis) {
|
||||
return null;
|
||||
}
|
||||
const { categories, emojis } = usePickerEmojis();
|
||||
|
||||
return (
|
||||
<PickerRaw
|
||||
data={EmojiData}
|
||||
custom={customEmojis}
|
||||
include={customCategories}
|
||||
custom={emojis}
|
||||
include={categories}
|
||||
set={set}
|
||||
sheetSize={sheetSize}
|
||||
sheetColumns={sheetColumns}
|
||||
@@ -57,6 +51,8 @@ export const Emoji: FC<EmojiProps> = ({
|
||||
const { mode } = useEmojiAppState();
|
||||
return (
|
||||
<EmojiRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- In React props are frozen, but the library we use is old so doesn't respect that.
|
||||
{...(EmojiRaw.defaultProps ?? {})}
|
||||
data={EmojiData}
|
||||
set={set}
|
||||
sheetSize={sheetSize}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { initialState } from '@/mastodon/initial_state';
|
||||
|
||||
import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { reloadCustomEmojis } from './picker';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
import type { EmojiWorkerMessage } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
@@ -11,9 +9,10 @@ const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
let worker: Worker | null = null;
|
||||
|
||||
const log = emojiLogger('index');
|
||||
const workerLog = emojiLogger('worker');
|
||||
|
||||
// This is too short, but better to fallback quickly than wait.
|
||||
const WORKER_TIMEOUT = 1_000;
|
||||
const WORKER_TIMEOUT = 2_000;
|
||||
|
||||
export async function initializeEmoji() {
|
||||
log('initializing emojis');
|
||||
@@ -39,48 +38,60 @@ export async function initializeEmoji() {
|
||||
void fallbackLoad();
|
||||
}, WORKER_TIMEOUT);
|
||||
|
||||
tempWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
tempWorker.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<EmojiWorkerMessage>) => {
|
||||
const { data: message } = event;
|
||||
|
||||
worker ??= tempWorker;
|
||||
|
||||
if (message === 'ready') {
|
||||
log('worker ready, loading data');
|
||||
worker ??= tempWorker;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const { type } = message;
|
||||
if (type === 'log') {
|
||||
workerLog(message.message);
|
||||
} else if (type === 'done' && message.storeName === 'custom') {
|
||||
void loadEmojisToStore();
|
||||
}
|
||||
|
||||
if (type !== 'ready') {
|
||||
return; // Exit for other messages.
|
||||
}
|
||||
|
||||
const debugValue = localStorage.getItem('debug');
|
||||
if (debugValue) {
|
||||
messageWorker({ type: 'debug', debugValue });
|
||||
}
|
||||
|
||||
workerLog('loading data');
|
||||
messageWorker(userLocale);
|
||||
messageWorker('custom');
|
||||
messageWorker('shortcodes');
|
||||
void loadCustomEmoji();
|
||||
void loadEmojiLocale(userLocale);
|
||||
} else {
|
||||
log('got worker message: %s', message);
|
||||
}
|
||||
});
|
||||
void loadEmojisToStore();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fallbackLoad() {
|
||||
log('falling back to main thread for loading');
|
||||
|
||||
await loadCustomEmoji();
|
||||
const { importLegacyShortcodes } = await import('./loader');
|
||||
const { importCustomEmojiData, importLegacyShortcodes, importEmojiData } =
|
||||
await import('./loader');
|
||||
|
||||
const customEmojis = await importCustomEmojiData();
|
||||
if (customEmojis && customEmojis.length > 0) {
|
||||
log('loaded %d custom emojis', customEmojis.length);
|
||||
}
|
||||
|
||||
const shortcodes = await importLegacyShortcodes();
|
||||
if (shortcodes?.length) {
|
||||
log('loaded %d legacy shortcodes', shortcodes.length);
|
||||
}
|
||||
await loadEmojiLocale(userLocale);
|
||||
}
|
||||
|
||||
async function loadEmojiLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const { importEmojiData } = await import('./loader');
|
||||
|
||||
if (worker) {
|
||||
log('asking worker to load locale %s', locale);
|
||||
messageWorker(locale);
|
||||
} else {
|
||||
const emojis = await importEmojiData(locale);
|
||||
if (emojis) {
|
||||
log('loaded %d emojis to locale %s', emojis.length, locale);
|
||||
}
|
||||
const emojis = await importEmojiData(userLocale);
|
||||
if (emojis) {
|
||||
log('loaded %d emojis to locale %s', emojis.length, userLocale);
|
||||
}
|
||||
await loadEmojisToStore();
|
||||
}
|
||||
|
||||
export async function loadCustomEmoji() {
|
||||
@@ -91,16 +102,32 @@ export async function loadCustomEmoji() {
|
||||
const emojis = await importCustomEmojiData();
|
||||
if (emojis && emojis.length > 0) {
|
||||
log('loaded %d custom emojis', emojis.length);
|
||||
await reloadCustomEmojis();
|
||||
}
|
||||
}
|
||||
await loadEmojisToStore();
|
||||
}
|
||||
|
||||
function messageWorker(
|
||||
locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES,
|
||||
) {
|
||||
function messageWorker(data: EmojiWorkerMessage | string) {
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
worker.postMessage({ locale });
|
||||
if (typeof data === 'string') {
|
||||
worker.postMessage({
|
||||
type: 'load',
|
||||
storeName: data,
|
||||
} satisfies EmojiWorkerMessage);
|
||||
} else {
|
||||
worker.postMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmojisToStore() {
|
||||
const { store } = await import('@/mastodon/store');
|
||||
const { loadCustomEmojis, loadLocale } =
|
||||
await import('@/mastodon/reducers/slices/emojis');
|
||||
|
||||
loadLocale(userLocale);
|
||||
await store.dispatch(loadCustomEmojis());
|
||||
|
||||
log('loaded emoji data into store');
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export function useEmojiAppState(): EmojiAppState {
|
||||
|
||||
return {
|
||||
currentLocale: locale,
|
||||
locales: [locale],
|
||||
mode,
|
||||
darkTheme: isDarkMode(),
|
||||
assetHost,
|
||||
@@ -64,7 +63,6 @@ export function getEmojiAppState(): EmojiAppState {
|
||||
|
||||
return {
|
||||
currentLocale,
|
||||
locales: [currentLocale],
|
||||
mode: determineEmojiMode(emojiStyle),
|
||||
darkTheme: isDarkMode(),
|
||||
assetHost,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename, resolve } from 'path';
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||
|
||||
import { unicodeToTwemojiHex } from './normalize';
|
||||
import { extractTokens, unicodeToTwemojiHex } from './normalize';
|
||||
|
||||
const emojiSVGFiles = await readdir(
|
||||
// This assumes tests are run from project root
|
||||
@@ -33,3 +33,32 @@ describe('unicodeToTwemojiHex', () => {
|
||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokens', () => {
|
||||
test('returns an empty array for blank input', () => {
|
||||
expect(extractTokens(' ', null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('check token word breaking with Intl.Segmenter', () => {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
|
||||
|
||||
expect(
|
||||
extractTokens('thumbs_up smiling-face camelCase', segmenter),
|
||||
).toEqual(['thumbs', 'up', 'smiling', 'face', 'camel', 'case']);
|
||||
});
|
||||
|
||||
test('check token word breaking with regex', () => {
|
||||
expect(extractTokens('Smile_face joy-test A ok 7 z', null)).toEqual([
|
||||
'smile',
|
||||
'face',
|
||||
'joy',
|
||||
'test',
|
||||
'ok',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensure +1 and -1 are preserved', () => {
|
||||
expect(extractTokens('+1', null)).toEqual(['+1']);
|
||||
expect(extractTokens('-1', null)).toEqual(['-1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,11 @@ export function extractTokens(
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
|
||||
// Handle the edge case of thumbs up and down emoticons.
|
||||
if (input === '+1' || input === '-1') {
|
||||
return [input];
|
||||
}
|
||||
|
||||
// Prefer to use Intl.Segmenter if available for better locale support.
|
||||
if (segmenter) {
|
||||
for (const { isWordLike, segment } of segmenter.segment(
|
||||
|
||||
@@ -1,79 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { CategoryName, CustomEmoji } from 'emoji-mart';
|
||||
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
const log = emojiLogger('picker');
|
||||
|
||||
let customEmojis: CustomEmoji[] | null = null;
|
||||
let customCategories = [
|
||||
'recent',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
] as CategoryName[];
|
||||
|
||||
export async function fetchCustomEmojiData() {
|
||||
if (customEmojis !== null) {
|
||||
return customEmojis;
|
||||
}
|
||||
|
||||
const { loadAllCustomEmoji } = await import('./database');
|
||||
const emojisRaw = await loadAllCustomEmoji();
|
||||
|
||||
// If it returns null then custom emojis aren't even loaded yet.
|
||||
if (emojisRaw === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If it's empty, then they are loaded but there aren't any.
|
||||
if (emojisRaw.length === 0) {
|
||||
customEmojis = [];
|
||||
return customEmojis;
|
||||
}
|
||||
|
||||
const categories = new Set(['custom']);
|
||||
const emojis = [];
|
||||
for (const emoji of emojisRaw) {
|
||||
const name = emoji.shortcode.replaceAll(':', '');
|
||||
emojis.push({
|
||||
name,
|
||||
id: name,
|
||||
custom: true,
|
||||
short_names: [name],
|
||||
imageUrl: autoPlayGif ? emoji.url : emoji.static_url,
|
||||
customCategory: emoji.category,
|
||||
});
|
||||
|
||||
if (emoji.category) {
|
||||
categories.add(`custom-${emoji.category}`);
|
||||
}
|
||||
}
|
||||
|
||||
customEmojis = emojis.toSorted((a, b) => {
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
customCategories = customCategories.toSpliced(
|
||||
1,
|
||||
0,
|
||||
...(Array.from(categories).toSorted() as CategoryName[]),
|
||||
);
|
||||
log(
|
||||
'loaded %d custom emojis in %d categories',
|
||||
customEmojis.length,
|
||||
categories.size,
|
||||
);
|
||||
|
||||
return customEmojis;
|
||||
}
|
||||
const searchCache = createLimitedCache<LegacyEmoji[]>({ maxSize: 10, log });
|
||||
|
||||
type LegacyEmoji =
|
||||
| { id: string; custom?: false; native: string }
|
||||
@@ -82,15 +20,6 @@ type LegacyEmoji =
|
||||
custom: true;
|
||||
};
|
||||
|
||||
export async function reloadCustomEmojis() {
|
||||
customEmojis = null;
|
||||
|
||||
const { loadEmojisIntoCache } =
|
||||
await import('@/mastodon/hooks/useCustomEmojis');
|
||||
|
||||
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
|
||||
}
|
||||
|
||||
// Replicates the old legacy search function.
|
||||
export async function emojiMartSearch(
|
||||
token: string,
|
||||
@@ -102,31 +31,85 @@ export async function emojiMartSearch(
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheKey = `${query}|${locale}|${limit}`;
|
||||
const cachedResult = searchCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const { search } = await import('./database');
|
||||
const results = await search({ query, locale, limit });
|
||||
return results.map((emoji) =>
|
||||
const legacyResults = results.map((emoji) =>
|
||||
'shortcode' in emoji
|
||||
? { id: emoji.shortcode, custom: true }
|
||||
? ({ id: emoji.shortcode, custom: true } as const)
|
||||
: {
|
||||
id: emoji.label.replaceAll(' ', '_').toLowerCase(),
|
||||
native: emoji.unicode,
|
||||
},
|
||||
);
|
||||
searchCache.set(cacheKey, legacyResults);
|
||||
|
||||
return legacyResults;
|
||||
}
|
||||
|
||||
const defaultCategories = [
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
] as CategoryName[];
|
||||
|
||||
const selectPickerData = createAppSelector(
|
||||
[(state) => state.emojis.custom, (state) => state.emojis.customCategories],
|
||||
(emojis, categories) => {
|
||||
// Create a map of shortcode to category name.
|
||||
const categoryMap = new Map<string, string>();
|
||||
for (const category in categories) {
|
||||
const catEmojis = categories[category];
|
||||
if (!catEmojis?.length) {
|
||||
continue;
|
||||
}
|
||||
for (const shortcode of catEmojis) {
|
||||
categoryMap.set(shortcode, category);
|
||||
}
|
||||
}
|
||||
|
||||
const customEmojis: CustomEmoji[] = [];
|
||||
for (const shortcode in emojis) {
|
||||
const emoji = emojis[shortcode];
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
|
||||
customEmojis.push({
|
||||
name: shortcode,
|
||||
id: shortcode,
|
||||
custom: true,
|
||||
short_names: [shortcode],
|
||||
imageUrl: autoPlayGif ? emoji.url : emoji.static_url,
|
||||
customCategory: categoryMap.get(shortcode),
|
||||
} as CustomEmoji);
|
||||
}
|
||||
|
||||
searchCache.clear();
|
||||
log('regenerated the picker data');
|
||||
|
||||
return {
|
||||
emojis: customEmojis,
|
||||
categories: [
|
||||
'recent',
|
||||
'custom',
|
||||
...Object.keys(categories).toSorted(),
|
||||
...defaultCategories,
|
||||
] as CategoryName[],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export function usePickerEmojis() {
|
||||
const [, setLoaded] = useState(customEmojis !== null);
|
||||
|
||||
useEffect(() => {
|
||||
if (customEmojis === null) {
|
||||
void fetchCustomEmojiData().then(() => {
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
customEmojis,
|
||||
customCategories,
|
||||
};
|
||||
return useAppSelector(selectPickerData);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function updateHtmlWithEmoji({
|
||||
}: {
|
||||
element: Element;
|
||||
locale: string;
|
||||
} & Omit<EmojiAppState, 'currentLocale' | 'locales'>) {
|
||||
} & Omit<EmojiAppState, 'currentLocale'>) {
|
||||
if (mode === EMOJI_MODE_NATIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export type CacheKey =
|
||||
| LocaleWithShortcodes;
|
||||
|
||||
export interface EmojiAppState {
|
||||
locales: Locale[];
|
||||
currentLocale: Locale;
|
||||
mode: EmojiMode;
|
||||
darkTheme: boolean;
|
||||
@@ -80,3 +79,23 @@ export type ExtraCustomEmojiMap = Record<
|
||||
string,
|
||||
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||
>;
|
||||
|
||||
export type EmojiWorkerMessage =
|
||||
| { type: 'ready' }
|
||||
| {
|
||||
type: 'load';
|
||||
storeName: string;
|
||||
}
|
||||
| {
|
||||
type: 'done';
|
||||
storeName: string;
|
||||
importCount: number;
|
||||
}
|
||||
| {
|
||||
type: 'log';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'debug';
|
||||
debugValue: string;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import { emojiRegexPolyfill } from '@/mastodon/polyfills';
|
||||
import { VARIATION_SELECTOR_CODE } from './constants';
|
||||
|
||||
export function emojiLogger(segment: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return debug(`emojis:worker:${segment}`);
|
||||
}
|
||||
return debug(`emojis:${segment}`);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user