From b4fb25643a12e13b909f5681ce747074c341cd7c Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 9 Feb 2026 14:14:24 +0100 Subject: [PATCH 1/9] Fix: Changes to pins update immediately (#37765) --- .../mastodon/actions/interactions.js | 3 + .../mastodon/actions/timelines.test.ts | 25 ++++++ .../mastodon/actions/timelines_typed.ts | 80 ++++++++++++++++++- app/javascript/mastodon/reducers/timelines.js | 4 +- 4 files changed, 109 insertions(+), 3 deletions(-) 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()); From c0f809cb5b5181e4c576c4de65ef8b3e1b6e24b5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:57:47 +0100 Subject: [PATCH 2/9] Update actions/checkout action to v6 (#37782) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-container-image.yml | 4 ++-- .github/workflows/build-push-pr.yml | 2 +- .github/workflows/bundler-audit.yml | 2 +- .github/workflows/bundlesize-compare.yml | 4 ++-- .github/workflows/check-i18n.yml | 2 +- .github/workflows/chromatic.yml | 4 ++-- .github/workflows/codeql.yml | 2 +- .github/workflows/crowdin-download-stable.yml | 2 +- .github/workflows/crowdin-download.yml | 2 +- .github/workflows/crowdin-upload.yml | 2 +- .github/workflows/format-check.yml | 2 +- .github/workflows/lint-css.yml | 2 +- .github/workflows/lint-haml.yml | 2 +- .github/workflows/lint-js.yml | 2 +- .github/workflows/lint-ruby.yml | 2 +- .github/workflows/test-js.yml | 2 +- .github/workflows/test-migrations.yml | 2 +- .github/workflows/test-ruby.yml | 8 ++++---- 18 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index 07669a574c..a6731585bd 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: @@ -119,7 +119,7 @@ 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 diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml index c8878873c2..e9953b9ffe 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..795c4ba812 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 @@ -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 }} 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 1b8b97952d..1acb3c0171 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? diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index d870177270..408f9b3515 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? diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index be83e153ff..faf01e41b4 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..e138417824 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 @@ -128,7 +128,7 @@ jobs: - '3.3' - '.ruby-version' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: @@ -222,7 +222,7 @@ jobs: - '.ruby-version' steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: @@ -360,7 +360,7 @@ jobs: search-image: opensearchproject/opensearch:2 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: From 624c25edd8e480b6a9765a207656bef6eaafd560 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:11:06 +0100 Subject: [PATCH 3/9] Update actions/cache action to v5 (#37781) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/setup-javascript/action.yml | 2 +- .github/workflows/test-ruby.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/test-ruby.yml b/.github/workflows/test-ruby.yml index e138417824..23c6be27dc 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -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 @@ -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 @@ -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') }} From c1b945425c759641e6d4d8ff5a4e01cb6466eed2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:11:10 +0100 Subject: [PATCH 4/9] Update peter-evans/create-pull-request action to v8 (#37783) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/crowdin-download-stable.yml | 2 +- .github/workflows/crowdin-download.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index 1acb3c0171..297e1c2f3c 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -50,7 +50,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 408f9b3515..1851b26a3a 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -52,7 +52,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)' From da91639b4385bb0bb96b0aeeb5cdded36ec9bf46 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:52:08 +0100 Subject: [PATCH 5/9] Update twk3/rollup-size-compare-action action to v1.2.0 (#37787) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/bundlesize-compare.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml index 795c4ba812..4111defb52 100644 --- a/.github/workflows/bundlesize-compare.yml +++ b/.github/workflows/bundlesize-compare.yml @@ -66,7 +66,7 @@ jobs: steps: - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 - - 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 From 665f746165ebd84a61cca5060f314a7ec771f526 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:06:12 +0100 Subject: [PATCH 6/9] Update artifact actions (major) (major) (#37788) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-container-image.yml | 4 ++-- .github/workflows/bundlesize-compare.yml | 6 +++--- .github/workflows/test-ruby.yml | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml index a6731585bd..595011a0c6 100644 --- a/.github/workflows/build-container-image.yml +++ b/.github/workflows/build-container-image.yml @@ -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 }} @@ -122,7 +122,7 @@ jobs: - 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/bundlesize-compare.yml b/.github/workflows/bundlesize-compare.yml index 4111defb52..d1b91aed96 100644 --- a/.github/workflows/bundlesize-compare.yml +++ b/.github/workflows/bundlesize-compare.yml @@ -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 @@ -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,7 +64,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - uses: twk3/rollup-size-compare-action@a1f8628fee0e40899ab2b46c1b6e14552b99281e # v1.2.0 with: diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 23c6be27dc..97d7cf1800 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -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: |- @@ -130,7 +130,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -224,7 +224,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: './' name: ${{ github.sha }} @@ -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 }} @@ -362,7 +362,7 @@ jobs: steps: - 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 From e17bbed88fb3067a6a8a6ab6f0809705f298ab28 Mon Sep 17 00:00:00 2001 From: Jonny Saunders Date: Mon, 9 Feb 2026 07:19:36 -0800 Subject: [PATCH 7/9] Change: Consolidate collection handling in jsonld helper (#34595) --- app/helpers/json_ld_helper.rb | 66 +++++++++++++++++++ .../fetch_featured_collection_service.rb | 24 +------ .../fetch_featured_tags_collection_service.rb | 35 +--------- .../activitypub/fetch_replies_service.rb | 47 ++----------- .../synchronize_followers_service.rb | 22 +------ 5 files changed, 80 insertions(+), 114 deletions(-) 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/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 From 0b8ce7200aa3121d07aa3866ddfc99de3bcf319e Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Mon, 9 Feb 2026 10:24:31 -0500 Subject: [PATCH 8/9] Use validation matchers for `ExistingUsernameValidator` spec (#37749) --- .../existing_username_validator_spec.rb | 80 +++++-------------- 1 file changed, 21 insertions(+), 59 deletions(-) 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 From 455fa541067f289b3de8e62233cc4cd215112266 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 9 Feb 2026 14:14:24 +0100 Subject: [PATCH 9/9] [Glitch] Fix: Changes to pins update immediately Port b4fb25643a12e13b909f5681ce747074c341cd7c to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/interactions.js | 3 + .../flavours/glitch/actions/timelines.test.ts | 25 ++++++ .../glitch/actions/timelines_typed.ts | 80 ++++++++++++++++++- .../flavours/glitch/reducers/timelines.js | 4 +- 4 files changed, 109 insertions(+), 3 deletions(-) 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());