diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 7982c17d61..3c9e06116c 100644 --- a/.github/actions/setup-javascript/action.yml +++ b/.github/actions/setup-javascript/action.yml @@ -23,7 +23,7 @@ runs: shell: bash run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 07669a574c..595011a0c6 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -35,7 +35,7 @@ jobs: - linux/arm64 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Prepare env: @@ -100,7 +100,7 @@ jobs: - name: Upload digest if: ${{ inputs.push_to_images != '' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: # `hashFiles` is used to disambiguate between streaming and non-streaming images name: digests-${{ hashFiles(inputs.file_to_build) }}-${{ env.PLATFORM_PAIR }} @@ -119,10 +119,10 @@ jobs: PUSH_TO_IMAGES: ${{ inputs.push_to_images }} steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download digests - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ runner.temp }}/digests # `hashFiles` is used to disambiguate between streaming and non-streaming images diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index 3952ed0a0b..ead1ecd082 100644 --- a/.github/workflows/build-push-pr.yml +++ b/.github/workflows/build-push-pr.yml @@ -18,7 +18,7 @@ jobs: steps: # Repository needs to be cloned so `git rev-parse` below works - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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 diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index c9ceb8fcab..f3a97f8a2e 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml index 1651f5cdae..d1b91aed96 100644 --- a/.github/workflows/bundlesize-compare.yml +++ b/.github/workflows/bundlesize-compare.yml @@ -17,7 +17,7 @@ jobs: env: ANALYZE_BUNDLE_SIZE: '1' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -26,7 +26,7 @@ jobs: run: yarn run build:production - name: Upload stats.json - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: head-stats path: ./stats.json @@ -40,7 +40,7 @@ jobs: env: ANALYZE_BUNDLE_SIZE: '1' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.base_ref }} @@ -51,7 +51,7 @@ jobs: run: yarn run build:production - name: Upload stats.json - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: base-stats path: ./stats.json @@ -64,9 +64,9 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - - uses: twk3/rollup-size-compare-action@5d3e409fcfe15d8ebb0edfe87e772c04b287f660 # v1.0.0 + - uses: twk3/rollup-size-compare-action@a1f8628fee0e40899ab2b46c1b6e14552b99281e # v1.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} current-stats-json-path: ./head-stats/stats.json diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 9ba0ddf3f7..6d660ec5bf 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 411f5ef02d..19548c28c1 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -16,7 +16,7 @@ jobs: changed: ${{ steps.filter.outputs.src }} steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 @@ -42,7 +42,7 @@ jobs: if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' steps: - name: Checkout code - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f538d0787..4e55219f55 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 0597a2373b..0c1f15efed 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? @@ -51,7 +51,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations for ${{ github.base_ref || github.ref_name }} (automated)' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 6c96630b47..9a07e0593b 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Increase Git http.postBuffer # This is needed due to a bug in Ubuntu's cURL version? @@ -53,7 +53,7 @@ jobs: # Create or update the pull request - name: Create Pull Request - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 with: commit-message: 'New Crowdin translations' title: 'New Crowdin Translations (automated)' diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 815018199f..d31272dc75 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: crowdin action uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2 diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 0177f1c271..528813c73a 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 9f9edba3f7..178342ed34 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 2abe2e96d9..637c211700 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 705c28a12e..7036c5e569 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 7fc1371c18..c70e2ede05 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1 diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index c85754fd2f..d1b55a5e0b 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Javascript environment uses: ./.github/actions/setup-javascript diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 03a7dcc44a..f33bf981cc 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -72,7 +72,7 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index decfc7ef47..97d7cf1800 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -32,7 +32,7 @@ jobs: SECRET_KEY_BASE_DUMMY: 1 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby environment uses: ./.github/actions/setup-ruby @@ -43,7 +43,7 @@ jobs: onlyProduction: 'true' - name: Cache assets from compilation - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | public/assets @@ -65,7 +65,7 @@ jobs: run: | tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json - - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: matrix.mode == 'test' with: path: |- @@ -128,9 +128,9 @@ jobs: - '3.3' - '.ruby-version' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -151,7 +151,7 @@ jobs: bin/flatware fan bin/rails db:test:prepare - name: Cache RSpec persistence file - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | tmp/rspec/examples.txt @@ -222,9 +222,9 @@ jobs: - '.ruby-version' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -247,7 +247,7 @@ jobs: - name: Cache Playwright Chromium browser id: playwright-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.cache/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -263,14 +263,14 @@ jobs: - run: bin/rspec spec/system --tag streaming --tag js - name: Archive logs - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: e2e-screenshots-${{ matrix.ruby-version }} @@ -360,9 +360,9 @@ jobs: search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -382,14 +382,14 @@ jobs: - run: bin/rspec --tag search - name: Archive logs - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-logs-${{ matrix.ruby-version }} path: log/ - name: Archive test screenshots - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 if: failure() with: name: test-search-screenshots diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 675d8b8730..804cc72d70 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -226,6 +226,72 @@ module JsonLdHelper end end + # Iterate through the pages of an activitypub collection, + # returning the collected items and the number of pages that were fetched. + # + # @param collection_or_uri [String, Hash] + # either the URI or an already-fetched AP object + # @param max_pages [Integer, nil] + # Max pages to fetch, if nil, fetch until no more pages + # @param max_items [Integer, nil] + # Max items to fetch, if nil, fetch until no more items + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Array, Integer>, nil] + # The collection items and the number of pages fetched + def collection_items(collection_or_uri, max_pages: 1, max_items: nil, reference_uri: nil, on_behalf_of: nil) + collection = fetch_collection_page(collection_or_uri, reference_uri: reference_uri, on_behalf_of: on_behalf_of) + return unless collection.is_a?(Hash) + + collection = fetch_collection_page(collection['first'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) if collection['first'].present? + return unless collection.is_a?(Hash) + + items = [] + n_pages = 1 + while collection.is_a?(Hash) + items.concat(as_array(collection_page_items(collection))) + + break if !max_items.nil? && items.size >= max_items + break if !max_pages.nil? && n_pages >= max_pages + + collection = collection['next'].present? ? fetch_collection_page(collection['next'], reference_uri: reference_uri, on_behalf_of: on_behalf_of) : nil + n_pages += 1 + end + + [items, n_pages] + end + + def collection_page_items(collection) + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + # Fetch a single collection page + # To get the whole collection, use collection_items + # + # @param collection_or_uri [String, Hash] + # @param reference_uri [String, nil] + # If not nil, a URI to compare to the collection URI. + # If the host of the collection URI does not match the reference URI, + # do not fetch the collection page. + # @param on_behalf_of [Account, nil] + # Sign the request on behalf of the Account, if not nil + # @return [Hash, nil] + def fetch_collection_page(collection_or_uri, reference_uri: nil, on_behalf_of: nil) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return if !reference_uri.nil? && non_matching_uri_hosts?(reference_uri, collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, on_behalf_of, raise_on_error: :temporary) + end + def valid_activitypub_content_type?(response) return true if response.mime_type == 'application/activity+json' diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 92142d782c..38694d83e5 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -6,6 +6,7 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { timelineExpandPinnedFromStatus } from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -368,6 +369,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -406,6 +408,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/flavours/glitch/actions/timelines.test.ts b/app/javascript/flavours/glitch/actions/timelines.test.ts index e7f4198cde..239692dd34 100644 --- a/app/javascript/flavours/glitch/actions/timelines.test.ts +++ b/app/javascript/flavours/glitch/actions/timelines.test.ts @@ -57,4 +57,29 @@ describe('parseTimelineKey', () => { tagged: 'nature', }); }); + + test('parses legacy account timeline key with pinned correctly', () => { + const params = parseTimelineKey('account:789:pinned:nature'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: false, + pinned: true, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with media correctly', () => { + const params = parseTimelineKey('account:789:media'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: true, + pinned: false, + }); + }); }); diff --git a/app/javascript/flavours/glitch/actions/timelines_typed.ts b/app/javascript/flavours/glitch/actions/timelines_typed.ts index 8d4c7a0178..dd8f160b61 100644 --- a/app/javascript/flavours/glitch/actions/timelines_typed.ts +++ b/app/javascript/flavours/glitch/actions/timelines_typed.ts @@ -1,10 +1,16 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; +import type { Status } from '../models/status'; import { createAppThunk } from '../store/typed_functions'; -import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { + expandAccountFeaturedTimeline, + expandTimeline, + TIMELINE_NON_STATUS_MARKERS, +} from './timelines'; export const expandTimelineByKey = createAppThunk( (args: { key: string; maxId?: number }, { dispatch }) => { @@ -119,8 +125,25 @@ export function parseTimelineKey(key: string): TimelineParams | null { type: 'account', userId, tagged: segments[3], + pinned: false, + boosts: false, + replies: false, + media: false, }; + // Handle legacy keys. + const flagsSegment = segments[2]; + if (!flagsSegment || !/^[01]{4}$/.test(flagsSegment)) { + if (flagsSegment === 'pinned') { + parsed.pinned = true; + } else if (flagsSegment === 'with_replies') { + parsed.replies = true; + } else if (flagsSegment === 'media') { + parsed.media = true; + } + return parsed; + } + const view = segments[2]?.split('') ?? []; for (let i = 0; i < view.length; i++) { const flagName = ACCOUNT_FILTERS[i]; @@ -150,6 +173,11 @@ export function parseTimelineKey(key: string): TimelineParams | null { return null; } +export function isTimelineKeyPinned(key: string) { + const parsedKey = parseTimelineKey(key); + return parsedKey?.type === 'account' && parsedKey.pinned; +} + export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); } @@ -170,3 +198,53 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineExpandPinnedFromStatus = createAppThunk( + (status: Status, { dispatch, getState }) => { + const accountId = status.getIn(['account', 'id']) as string; + if (!accountId) { + return; + } + + // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const timelines = getState().timelines as ImmutableMap; + if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + }), + ); + void dispatch(expandAccountFeaturedTimeline(accountId)); + + // Iterate over tags and clear those too. + const tags = status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined; + if (!tags) { + return; + } + tags.forEach((tag) => { + const tagName = tag.get('name'); + if (!tagName) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + tagged: tagName, + }), + ); + void dispatch( + expandAccountFeaturedTimeline(accountId, { tagged: tagName }), + ); + }); + }, +); diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index 3a7da91651..d50df05344 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -1,6 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; -import { timelineDelete, isNonStatusId } from 'flavours/glitch/actions/timelines_typed'; import { blockAccountSuccess, @@ -21,6 +20,7 @@ import { TIMELINE_GAP, disconnectTimeline, } from '../actions/timelines'; +import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; import { compareId } from '../compare_id'; const initialState = ImmutableMap(); @@ -50,7 +50,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (!next && !isLoadingRecent) mMap.set('hasMore', false); - if (timeline.endsWith(':pinned')) { + if (isTimelineKeyPinned(timeline)) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty()); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index d3538a8850..437f597314 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -6,6 +6,7 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { timelineExpandPinnedFromStatus } from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -368,6 +369,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -406,6 +408,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/mastodon/actions/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts index e7f4198cde..239692dd34 100644 --- a/app/javascript/mastodon/actions/timelines.test.ts +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -57,4 +57,29 @@ describe('parseTimelineKey', () => { tagged: 'nature', }); }); + + test('parses legacy account timeline key with pinned correctly', () => { + const params = parseTimelineKey('account:789:pinned:nature'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: false, + pinned: true, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with media correctly', () => { + const params = parseTimelineKey('account:789:media'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: true, + pinned: false, + }); + }); }); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index d1fcfb1c65..f07b1274e2 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -1,10 +1,16 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import type { Status } from '../models/status'; import { createAppThunk } from '../store/typed_functions'; -import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { + expandAccountFeaturedTimeline, + expandTimeline, + TIMELINE_NON_STATUS_MARKERS, +} from './timelines'; export const expandTimelineByKey = createAppThunk( (args: { key: string; maxId?: number }, { dispatch }) => { @@ -119,8 +125,25 @@ export function parseTimelineKey(key: string): TimelineParams | null { type: 'account', userId, tagged: segments[3], + pinned: false, + boosts: false, + replies: false, + media: false, }; + // Handle legacy keys. + const flagsSegment = segments[2]; + if (!flagsSegment || !/^[01]{4}$/.test(flagsSegment)) { + if (flagsSegment === 'pinned') { + parsed.pinned = true; + } else if (flagsSegment === 'with_replies') { + parsed.replies = true; + } else if (flagsSegment === 'media') { + parsed.media = true; + } + return parsed; + } + const view = segments[2]?.split('') ?? []; for (let i = 0; i < view.length; i++) { const flagName = ACCOUNT_FILTERS[i]; @@ -150,6 +173,11 @@ export function parseTimelineKey(key: string): TimelineParams | null { return null; } +export function isTimelineKeyPinned(key: string) { + const parsedKey = parseTimelineKey(key); + return parsedKey?.type === 'account' && parsedKey.pinned; +} + export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); } @@ -170,3 +198,53 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineExpandPinnedFromStatus = createAppThunk( + (status: Status, { dispatch, getState }) => { + const accountId = status.getIn(['account', 'id']) as string; + if (!accountId) { + return; + } + + // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const timelines = getState().timelines as ImmutableMap; + if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + }), + ); + void dispatch(expandAccountFeaturedTimeline(accountId)); + + // Iterate over tags and clear those too. + const tags = status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined; + if (!tags) { + return; + } + tags.forEach((tag) => { + const tagName = tag.get('name'); + if (!tagName) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + tagged: tagName, + }), + ); + void dispatch( + expandAccountFeaturedTimeline(accountId, { tagged: tagName }), + ); + }); + }, +); diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index e915fa7070..df0a26bf8d 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -1,6 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; -import { timelineDelete, isNonStatusId } from 'mastodon/actions/timelines_typed'; import { blockAccountSuccess, @@ -21,6 +20,7 @@ import { TIMELINE_GAP, disconnectTimeline, } from '../actions/timelines'; +import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; import { compareId } from '../compare_id'; const initialState = ImmutableMap(); @@ -50,7 +50,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (!next && !isLoadingRecent) mMap.set('hasMore', false); - if (timeline.endsWith(':pinned')) { + if (isTimelineKeyPinned(timeline)) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty()); diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index 352e1cbf5b..1ed19b35ee 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -8,33 +8,15 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService @account = account @options = options - @json = fetch_collection(options[:collection].presence || @account.featured_collection_url) + @json = fetch_collection_page(options[:collection].presence || @account.featured_collection_url) return if @json.blank? - process_items(collection_items(@json)) + @items, = collection_items(@json, max_pages: 1, reference_uri: @account.uri, on_behalf_of: local_follower) + process_items(@items) end private - def collection_items(collection) - collection = fetch_collection(collection['first']) if collection['first'].present? - return unless collection.is_a?(Hash) - - case collection['type'] - when 'Collection', 'CollectionPage' - as_array(collection['items']) - when 'OrderedCollection', 'OrderedCollectionPage' - as_array(collection['orderedItems']) - end - end - - def fetch_collection(collection_or_uri) - return collection_or_uri if collection_or_uri.is_a?(Hash) - return if non_matching_uri_hosts?(@account.uri, collection_or_uri) - - fetch_resource_without_id_validation(collection_or_uri, local_follower, raise_on_error: :temporary) - end - def process_items(items) return if items.nil? diff --git a/app/services/activitypub/fetch_featured_tags_collection_service.rb b/app/services/activitypub/fetch_featured_tags_collection_service.rb index ec2422a075..92ef5c07d3 100644 --- a/app/services/activitypub/fetch_featured_tags_collection_service.rb +++ b/app/services/activitypub/fetch_featured_tags_collection_service.rb @@ -11,43 +11,12 @@ class ActivityPub::FetchFeaturedTagsCollectionService < BaseService return unless supported_context?(@json) - process_items(collection_items(@json)) + @items, = collection_items(@json, max_items: FeaturedTag::LIMIT, max_pages: FeaturedTag::LIMIT, reference_uri: @account.uri, on_behalf_of: local_follower) + process_items(@items) end private - def collection_items(collection) - all_items = [] - - collection = fetch_collection(collection['first']) if collection['first'].present? - - while collection.is_a?(Hash) - items = case collection['type'] - when 'Collection', 'CollectionPage' - collection['items'] - when 'OrderedCollection', 'OrderedCollectionPage' - collection['orderedItems'] - end - - break if items.blank? - - all_items.concat(items) - - break if all_items.size >= FeaturedTag::LIMIT - - collection = collection['next'].present? ? fetch_collection(collection['next']) : nil - end - - all_items - end - - def fetch_collection(collection_or_uri) - return collection_or_uri if collection_or_uri.is_a?(Hash) - return if non_matching_uri_hosts?(@account.uri, collection_or_uri) - - fetch_resource_without_id_validation(collection_or_uri, local_follower, raise_on_error: :temporary) - end - def process_items(items) names = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.take(FeaturedTag::LIMIT) tags = names.index_by { |name| HashtagNormalizer.new.normalize(name) } diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index 327c88d846..0a6ad047ab 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -8,9 +8,13 @@ class ActivityPub::FetchRepliesService < BaseService def call(reference_uri, collection_or_uri, max_pages: 1, allow_synchronous_requests: true, batch_id: nil, request_id: nil) @reference_uri = reference_uri - @allow_synchronous_requests = allow_synchronous_requests + return if !allow_synchronous_requests && !collection_or_uri.is_a?(Hash) - @items, n_pages = collection_items(collection_or_uri, max_pages: max_pages) + # if given a prefetched collection while forbidding synchronous requests, + # process it and return without fetching additional pages + max_pages = 1 if !allow_synchronous_requests && collection_or_uri.is_a?(Hash) + + @items, n_pages = collection_items(collection_or_uri, max_pages: max_pages, max_items: MAX_REPLIES, reference_uri: @reference_uri) return if @items.nil? @items = filter_replies(@items) @@ -26,45 +30,6 @@ class ActivityPub::FetchRepliesService < BaseService private - def collection_items(collection_or_uri, max_pages: 1) - collection = fetch_collection(collection_or_uri) - return unless collection.is_a?(Hash) - - collection = fetch_collection(collection['first']) if collection['first'].present? - return unless collection.is_a?(Hash) - - items = [] - n_pages = 1 - while collection.is_a?(Hash) - items.concat(as_array(collection_page_items(collection))) - - break if items.size >= MAX_REPLIES - break if n_pages >= max_pages - - collection = collection['next'].present? ? fetch_collection(collection['next']) : nil - n_pages += 1 - end - - [items, n_pages] - end - - def collection_page_items(collection) - case collection['type'] - when 'Collection', 'CollectionPage' - collection['items'] - when 'OrderedCollection', 'OrderedCollectionPage' - collection['orderedItems'] - end - end - - def fetch_collection(collection_or_uri) - return collection_or_uri if collection_or_uri.is_a?(Hash) - return unless @allow_synchronous_requests - return if non_matching_uri_hosts?(@reference_uri, collection_or_uri) - - fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary) - end - def filter_replies(items) # Only fetch replies to the same server as the original status to avoid # amplification attacks. diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index 837796edba..9e0b452929 100644 --- a/app/services/activitypub/synchronize_followers_service.rb +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -67,10 +67,10 @@ class ActivityPub::SynchronizeFollowersService < BaseService # Only returns true if the whole collection has been processed def process_collection!(collection_uri, max_pages: MAX_COLLECTION_PAGES) - collection = fetch_collection(collection_uri) + collection = fetch_collection_page(collection_uri, reference_uri: @account.uri) return false unless collection.is_a?(Hash) - collection = fetch_collection(collection['first']) if collection['first'].present? + collection = fetch_collection_page(collection['first'], reference_uri: @account.uri) if collection['first'].present? while collection.is_a?(Hash) process_page!(as_array(collection_page_items(collection))) @@ -80,25 +80,9 @@ class ActivityPub::SynchronizeFollowersService < BaseService return true if collection['next'].blank? # We reached the end of the collection return false if max_pages <= 0 # We reached our pages limit - collection = fetch_collection(collection['next']) + collection = fetch_collection_page(collection['next']) end false end - - def collection_page_items(collection) - case collection['type'] - when 'Collection', 'CollectionPage' - collection['items'] - when 'OrderedCollection', 'OrderedCollectionPage' - collection['orderedItems'] - end - end - - def fetch_collection(collection_or_uri) - return collection_or_uri if collection_or_uri.is_a?(Hash) - return if non_matching_uri_hosts?(@account.uri, collection_or_uri) - - fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary) - end end diff --git a/spec/validators/existing_username_validator_spec.rb b/spec/validators/existing_username_validator_spec.rb index ab5be52453..9620b96aee 100644 --- a/spec/validators/existing_username_validator_spec.rb +++ b/spec/validators/existing_username_validator_spec.rb @@ -3,82 +3,44 @@ require 'rails_helper' RSpec.describe ExistingUsernameValidator do + subject { record_class.new } + let(:record_class) do Class.new do include ActiveModel::Validations attr_accessor :contact, :friends - def self.name - 'Record' - end + def self.name = 'Record' validates :contact, existing_username: true validates :friends, existing_username: { multiple: true } end end - let(:record) { record_class.new } - describe '#validate_each' do - context 'with a nil value' do - it 'does not add errors' do - record.contact = nil + context 'with a nil value' do + it { is_expected.to allow_value(nil).for(:contact) } + end - expect(record).to be_valid - expect(record.errors).to be_empty + context 'when there are no accounts' do + it { is_expected.to_not allow_value('user@example.com').for(:contact).with_message(I18n.t('existing_username_validator.not_found')) } + end + + context 'when there are accounts' do + before { Fabricate(:account, domain: 'example.com', username: 'user') } + + context 'when the value does not match' do + it { is_expected.to_not allow_value('friend@other.host').for(:contact).with_message(I18n.t('existing_username_validator.not_found')) } + + context 'when multiple is true' do + it { is_expected.to_not allow_value('friend@other.host').for(:friends).with_message(I18n.t('existing_username_validator.not_found_multiple', usernames: 'friend@other.host')) } end end - context 'when there are no accounts' do - it 'adds errors to the record' do - record.contact = 'user@example.com' + context 'when the value does match' do + it { is_expected.to allow_value('user@example.com').for(:contact) } - expect(record).to_not be_valid - expect(record.errors.first.attribute).to eq(:contact) - expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found') - end - end - - context 'when there are accounts' do - before { Fabricate(:account, domain: 'example.com', username: 'user') } - - context 'when the value does not match' do - it 'adds errors to the record' do - record.contact = 'friend@other.host' - - expect(record).to_not be_valid - expect(record.errors.first.attribute).to eq(:contact) - expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found') - end - - context 'when multiple is true' do - it 'adds errors to the record' do - record.friends = 'friend@other.host' - - expect(record).to_not be_valid - expect(record.errors.first.attribute).to eq(:friends) - expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found_multiple', usernames: 'friend@other.host') - end - end - end - - context 'when the value does match' do - it 'does not add errors to the record' do - record.contact = 'user@example.com' - - expect(record).to be_valid - expect(record.errors).to be_empty - end - - context 'when multiple is true' do - it 'does not add errors to the record' do - record.friends = 'user@example.com' - - expect(record).to be_valid - expect(record.errors).to be_empty - end - end - end + it { is_expected.to allow_value('user@example.com').for(:friends) } end end end