diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 07fd4d08d37455..808adc7de64f96 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@v3 + - uses: actions/cache@v4 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-security.yml b/.github/workflows/build-security.yml new file mode 100644 index 00000000000000..b03b787d5f73c6 --- /dev/null +++ b/.github/workflows/build-security.yml @@ -0,0 +1,64 @@ +name: Build security nightly container image +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + if: github.repository == 'mastodon/mastodon' + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date --date='next day' +'%Y-%m-%d')-security>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true + cache: false + push_to_images: | + tootsuite/mastodon + ghcr.io/mastodon/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: true + cache: false + push_to_images: | + tootsuite/mastodon-streaming + ghcr.io/mastodon/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=auto + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.rubocop.yml b/.rubocop.yml index 69989411443558..330c40de1b67b0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -174,6 +174,15 @@ Style/ClassAndModuleChildren: Style/Documentation: Enabled: false +# Reason: Route redirects are not token-formatted and must be skipped +# https://docs.rubocop.org/rubocop/cops_style.html#styleformatstringtoken +Style/FormatStringToken: + inherit_mode: + merge: + - AllowedMethods # The rubocop-rails config adds `redirect` + AllowedMethods: + - redirect_with_vary + # Reason: Enforce modern Ruby style # https://docs.rubocop.org/rubocop/cops_style.html#stylehashsyntax Style/HashSyntax: diff --git a/Gemfile b/Gemfile index 4951304e39e244..906441ec67cb42 100644 --- a/Gemfile +++ b/Gemfile @@ -125,12 +125,6 @@ group :test do # Used to mock environment variables gem 'climate_control' - # Generating fake data for specs - gem 'faker', '~> 3.2' - - # Generate test objects for specs - gem 'fabrication', '~> 2.30' - # Add back helpers functions removed in Rails 5.1 gem 'rails-controller-testing', '~> 1.0' @@ -182,6 +176,12 @@ group :development, :test do # Interactive Debugging tools gem 'debug', '~> 1.8' + # Generate fake data values + gem 'faker', '~> 3.2' + + # Generate factory objects + gem 'fabrication', '~> 2.30' + # Profiling tools gem 'memory_profiler', require: false gem 'ruby-prof', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 57b25807222386..01f5b4592947dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,11 +167,11 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) @@ -180,7 +180,7 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.5.0) + chewy (7.5.1) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 35391e64c44390..92f1eb5a168280 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -266,7 +266,7 @@ def actor_from_key_id(key_id) stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_actor(key_id) - account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) } + account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, suppress_errors: false) } account end rescue Mastodon::PrivateNetworkAddressError => e diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index b3d0d032c4d3ce..cc05b7a403483e 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -155,8 +155,8 @@ def safe_for_forwarding?(original, compacted) end end - def fetch_resource(uri, id, on_behalf_of = nil, request_options: {}) - unless id + def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) + unless id_is_known json = fetch_resource_without_id_validation(uri, on_behalf_of) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js index 870a311024d12f..8eafe38b21ae0e 100644 --- a/app/javascript/mastodon/actions/suggestions.js +++ b/app/javascript/mastodon/actions/suggestions.js @@ -54,12 +54,5 @@ export const dismissSuggestion = accountId => (dispatch, getState) => { id: accountId, }); - api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v2/suggestions').then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }).catch(() => {}); + api(getState).delete(`/api/v1/suggestions/${accountId}`).catch(() => {}); }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 08561c71f49567..4ce7c3cf8414a3 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -21,6 +21,10 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL'; +export const TIMELINE_INSERT = 'TIMELINE_INSERT'; + +export const TIMELINE_SUGGESTIONS = 'inline-follow-suggestions'; +export const TIMELINE_GAP = null; export const loadPending = timeline => ({ type: TIMELINE_LOAD_PENDING, @@ -112,9 +116,19 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); + if (timelineId === 'home' && !isLoadingMore && !isLoadingRecent) { + const now = new Date(); + const fittingIndex = response.data.findIndex(status => now - (new Date(status.created_at)) > 4 * 3600 * 1000); + + if (fittingIndex !== -1) { + dispatch(insertIntoTimeline(timelineId, TIMELINE_SUGGESTIONS, Math.max(1, fittingIndex))); + } + } + if (timelineId === 'home') { dispatch(submitMarkers()); } @@ -221,3 +235,10 @@ export const markAsPartial = timeline => ({ type: TIMELINE_MARK_AS_PARTIAL, timeline, }); + +export const insertIntoTimeline = (timeline, key, index) => ({ + type: TIMELINE_INSERT, + timeline, + index, + key, +}); diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index e92dd233e150ec..3ed20f65eb3f2b 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -5,7 +5,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { debounce } from 'lodash'; +import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines'; import RegenerationIndicator from 'mastodon/components/regeneration_indicator'; +import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions'; import StatusContainer from '../containers/status_container'; @@ -91,25 +93,38 @@ export default class StatusList extends ImmutablePureComponent { } let scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map((statusId, index) => statusId === null ? ( - 0 ? statusIds.get(index - 1) : null} - onClick={onLoadMore} - /> - ) : ( - - )) + statusIds.map((statusId, index) => { + switch(statusId) { + case TIMELINE_SUGGESTIONS: + return ( + + ); + case TIMELINE_GAP: + return ( + 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ); + default: + return ( + + ); + } + }) ) : null; if (scrollableContent && featuredStatusIds) { diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index 9949d2c001007b..37017f4cc32021 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -164,6 +164,7 @@ class EmojiPickerMenuImpl extends PureComponent { intl: PropTypes.object.isRequired, skinTone: PropTypes.number.isRequired, onSkinTone: PropTypes.func.isRequired, + pickerButtonRef: PropTypes.func.isRequired }; static defaultProps = { @@ -178,7 +179,7 @@ class EmojiPickerMenuImpl extends PureComponent { }; handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { + if (this.node && !this.node.contains(e.target) && !this.props.pickerButtonRef.contains(e.target)) { this.props.onClose(); } }; @@ -233,6 +234,7 @@ class EmojiPickerMenuImpl extends PureComponent { emoji.native = emoji.colons; } if (!(event.ctrlKey || event.metaKey)) { + this.props.onClose(); } this.props.onPick(emoji); @@ -407,6 +409,7 @@ class EmojiPickerDropdown extends PureComponent { onSkinTone={onSkinTone} skinTone={skinTone} frequentlyUsedEmojis={frequentlyUsedEmojis} + pickerButtonRef={this.target} /> diff --git a/app/javascript/mastodon/features/compose/components/poll_form.jsx b/app/javascript/mastodon/features/compose/components/poll_form.jsx index 3c02810e7a29f5..b3566fd6f5ad7f 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.jsx +++ b/app/javascript/mastodon/features/compose/components/poll_form.jsx @@ -18,8 +18,6 @@ import AutosuggestInput from 'mastodon/components/autosuggest_input'; const messages = defineMessages({ option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' }, - add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add option' }, - remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this option' }, duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' }, type: { id: 'compose_form.poll.type', defaultMessage: 'Style' }, switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' }, diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx deleted file mode 100644 index 960d30e2caea4a..00000000000000 --- a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FormattedMessage } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import background from '@/images/friends-cropped.png'; -import { DismissableBanner } from 'mastodon/components/dismissable_banner'; - -export const ExplorePrompt = () => ( - - - -

- -

-

- -

- -
-
- - - - - - -
-
-
-); diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx new file mode 100644 index 00000000000000..ac414d04d6a810 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx @@ -0,0 +1,201 @@ +import PropTypes from 'prop-types'; +import { useEffect, useCallback, useRef, useState } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { useDispatch, useSelector } from 'react-redux'; + +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import { followAccount, unfollowAccount } from 'mastodon/actions/accounts'; +import { changeSetting } from 'mastodon/actions/settings'; +import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions'; +import { Avatar } from 'mastodon/components/avatar'; +import { Button } from 'mastodon/components/button'; +import { DisplayName } from 'mastodon/components/display_name'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { VerifiedBadge } from 'mastodon/components/verified_badge'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" }, +}); + +const Source = ({ id }) => { + let label; + + switch (id) { + case 'friends_of_friends': + case 'similar_to_recently_followed': + label = ; + break; + case 'featured': + label = ; + break; + case 'most_followed': + case 'most_interactions': + label = ; + break; + } + + return ( +
+ + {label} +
+ ); +}; + +Source.propTypes = { + id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']), +}; + +const Card = ({ id, source }) => { + const intl = useIntl(); + const account = useSelector(state => state.getIn(['accounts', id])); + const relationship = useSelector(state => state.getIn(['relationships', id])); + const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); + const dispatch = useDispatch(); + const following = relationship?.get('following') ?? relationship?.get('requested'); + + const handleFollow = useCallback(() => { + if (following) { + dispatch(unfollowAccount(id)); + } else { + dispatch(followAccount(id)); + } + }, [id, following, dispatch]); + + const handleDismiss = useCallback(() => { + dispatch(dismissSuggestion(id)); + }, [id, dispatch]); + + return ( +
+ + +
+ +
+ +
+ + {firstVerifiedField ? : } +
+ +
+ ); +}; + +Card.propTypes = { + id: PropTypes.string.isRequired, + source: ImmutablePropTypes.list, +}; + +const DISMISSIBLE_ID = 'home/follow-suggestions'; + +export const InlineFollowSuggestions = ({ hidden }) => { + const intl = useIntl(); + const dispatch = useDispatch(); + const suggestions = useSelector(state => state.getIn(['suggestions', 'items'])); + const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading'])); + const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID])); + const bodyRef = useRef(); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + useEffect(() => { + dispatch(fetchSuggestions()); + }, [dispatch]); + + useEffect(() => { + if (!bodyRef.current) { + return; + } + + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); + }, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]); + + const handleLeftNav = useCallback(() => { + bodyRef.current.scrollLeft -= 200; + }, [bodyRef]); + + const handleRightNav = useCallback(() => { + bodyRef.current.scrollLeft += 200; + }, [bodyRef]); + + const handleScroll = useCallback(() => { + if (!bodyRef.current) { + return; + } + + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth); + }, [setCanScrollRight, setCanScrollLeft, bodyRef]); + + const handleDismiss = useCallback(() => { + dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); + }, [dispatch]); + + if (dismissed || (!isLoading && suggestions.isEmpty())) { + return null; + } + + if (hidden) { + return ( +
+ ); + } + + return ( +
+
+

+ +
+ + +
+
+ +
+
+ {suggestions.map(suggestion => ( + + ))} +
+ + {canScrollLeft && ( + + )} + + {canScrollRight && ( + + )} +
+
+ ); +}; + +InlineFollowSuggestions.propTypes = { + hidden: PropTypes.bool, +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 069f52b0beaf2c..6e7dc2b6c8c8a7 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -6,8 +6,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Helmet } from 'react-helmet'; -import { createSelector } from '@reduxjs/toolkit'; -import { List as ImmutableList } from 'immutable'; import { connect } from 'react-redux'; import CampaignIcon from '@/material-icons/400-24px/campaign.svg?react'; @@ -16,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; -import { me, criticalUpdatesPending } from 'mastodon/initial_state'; +import { criticalUpdatesPending } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { expandHomeTimeline } from '../../actions/timelines'; @@ -26,7 +24,6 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; -import { ExplorePrompt } from './components/explore_prompt'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -34,51 +31,12 @@ const messages = defineMessages({ hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, }); -const getHomeFeedSpeed = createSelector([ - state => state.getIn(['timelines', 'home', 'items'], ImmutableList()), - state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()), - state => state.get('statuses'), -], (statusIds, pendingStatusIds, statusMap) => { - const recentStatusIds = pendingStatusIds.concat(statusIds); - const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); - - if (statuses.isEmpty()) { - return { - gap: 0, - newest: new Date(0), - }; - } - - const datetimes = statuses.map(status => status.get('created_at', 0)); - const oldest = new Date(datetimes.min()); - const newest = new Date(datetimes.max()); - const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds - - return { - gap: averageGap, - newest, - }; -}); - -const homeTooSlow = createSelector([ - state => state.getIn(['timelines', 'home', 'isLoading']), - state => state.getIn(['timelines', 'home', 'isPartial']), - getHomeFeedSpeed, -], (isLoading, isPartial, speed) => - !isLoading && !isPartial // Only if the home feed has finished loading - && ( - (speed.gap > (30 * 60) // If the average gap between posts is more than 30 minutes - || (Date.now() - speed.newest) > (1000 * 3600)) // If the most recent post is from over an hour ago - ) -); - const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, isPartial: state.getIn(['timelines', 'home', 'isPartial']), hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), showAnnouncements: state.getIn(['announcements', 'show']), - tooSlow: homeTooSlow(state), }); class HomeTimeline extends PureComponent { @@ -97,7 +55,6 @@ class HomeTimeline extends PureComponent { hasAnnouncements: PropTypes.bool, unreadAnnouncements: PropTypes.number, showAnnouncements: PropTypes.bool, - tooSlow: PropTypes.bool, }; handlePin = () => { @@ -167,7 +124,7 @@ class HomeTimeline extends PureComponent { }; render () { - const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; + const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const pinned = !!columnId; const { signedIn } = this.context.identity; const banners = []; @@ -192,10 +149,6 @@ class HomeTimeline extends PureComponent { banners.push(); } - if (tooSlow) { - banners.push(); - } - return ( createSelector([ (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { return statusIds.filter(id => { - if (id === null) return true; + if (id === null || id === 'inline-follow-suggestions') return true; const statusForId = statuses.get(id); let showStatus = true; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 4befe779490189..12d0068d69eafc 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -145,11 +145,9 @@ "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", "compose_form.lock_disclaimer.lock": "locked", "compose_form.placeholder": "What's on your mind?", - "compose_form.poll.add_option": "Add option", "compose_form.poll.duration": "Poll duration", "compose_form.poll.multiple": "Multiple choice", "compose_form.poll.option_placeholder": "Option {number}", - "compose_form.poll.remove_option": "Remove this option", "compose_form.poll.single": "Pick one", "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices", "compose_form.poll.switch_to_single": "Change poll to allow for a single choice", @@ -279,6 +277,12 @@ "follow_request.authorize": "Authorize", "follow_request.reject": "Reject", "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.", + "follow_suggestions.curated_suggestion": "Editors' Choice", + "follow_suggestions.dismiss": "Don't show again", + "follow_suggestions.personalized_suggestion": "Personalized suggestion", + "follow_suggestions.popular_suggestion": "Popular suggestion", + "follow_suggestions.view_all": "View all", + "follow_suggestions.who_to_follow": "Who to follow", "followed_tags": "Followed hashtags", "footer.about": "About", "footer.directory": "Profiles directory", @@ -305,13 +309,9 @@ "hashtag.follow": "Follow hashtag", "hashtag.unfollow": "Unfollow hashtag", "hashtags.and_other": "…and {count, plural, other {# more}}", - "home.actions.go_to_explore": "See what's trending", - "home.actions.go_to_suggestions": "Find people to follow", "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", - "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:", - "home.explore_prompt.title": "This is your home base within Mastodon.", "home.hide_announcements": "Hide announcements", "home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!", "home.pending_critical_update.link": "See updates", diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index a605ecbb8bd62d..0e353e0d1b7c51 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -104,7 +104,7 @@ const initialState = ImmutableMap({ dismissed_banners: ImmutableMap({ 'public_timeline': false, 'community_timeline': false, - 'home.explore_prompt': false, + 'home/follow-suggestions': false, 'explore/links': false, 'explore/statuses': false, 'explore/tags': false, diff --git a/app/javascript/mastodon/reducers/suggestions.js b/app/javascript/mastodon/reducers/suggestions.js index 0f224ff4b94f34..5b9d983dea4c73 100644 --- a/app/javascript/mastodon/reducers/suggestions.js +++ b/app/javascript/mastodon/reducers/suggestions.js @@ -28,12 +28,12 @@ export default function suggestionsReducer(state = initialState, action) { case SUGGESTIONS_FETCH_FAIL: return state.set('isLoading', false); case SUGGESTIONS_DISMISS: - return state.update('items', list => list.filterNot(x => x.account === action.id)); + return state.update('items', list => list.filterNot(x => x.get('account') === action.id)); case blockAccountSuccess.type: case muteAccountSuccess.type: - return state.update('items', list => list.filterNot(x => x.account === action.payload.relationship.id)); + return state.update('items', list => list.filterNot(x => x.get('account') === action.payload.relationship.id)); case blockDomainSuccess.type: - return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.account))); + return state.update('items', list => list.filterNot(x => action.payload.accounts.includes(x.get('account')))); default: return state; } diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 43dedd6e6d8cc8..4c9ab98a82ec65 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -17,6 +17,9 @@ import { TIMELINE_DISCONNECT, TIMELINE_LOAD_PENDING, TIMELINE_MARK_AS_PARTIAL, + TIMELINE_INSERT, + TIMELINE_GAP, + TIMELINE_SUGGESTIONS, } from '../actions/timelines'; import { compareId } from '../compare_id'; @@ -32,6 +35,8 @@ const initialTimeline = ImmutableMap({ items: ImmutableList(), }); +const isPlaceholder = value => value === TIMELINE_GAP || value === TIMELINE_SUGGESTIONS; + const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { // This method is pretty tricky because: // - existing items in the timeline might be out of order @@ -63,20 +68,20 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is // First, find the furthest (if properly sorted, oldest) item in the timeline that is // newer than the oldest fetched one, as it's most likely that it delimits the gap. // Start the gap *after* that item. - const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; + const lastIndex = oldIds.findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.last()) >= 0) + 1; // Then, try to find the furthest (if properly sorted, oldest) item in the timeline that // is newer than the most recent fetched one, as it delimits a section comprised of only // items older or within `newIds` (or that were deleted from the server, so should be removed // anyway). // Stop the gap *after* that item. - const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0) + 1; + const firstIndex = oldIds.take(lastIndex).findLastIndex(id => !isPlaceholder(id) && compareId(id, newIds.first()) > 0) + 1; let insertedIds = ImmutableOrderedSet(newIds).withMutations(insertedIds => { // It is possible, though unlikely, that the slice we are replacing contains items older // than the elements we got from the API. Get them and add them back at the back of the // slice. - const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => id !== null && compareId(id, newIds.last()) < 0); + const olderIds = oldIds.slice(firstIndex, lastIndex).filter(id => !isPlaceholder(id) && compareId(id, newIds.last()) < 0); insertedIds.union(olderIds); // Make sure we aren't inserting duplicates @@ -84,8 +89,8 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is }).toList(); // Finally, insert a gap marker if the data is marked as partial by the server - if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== null)) { - insertedIds = insertedIds.unshift(null); + if (isPartial && (firstIndex === 0 || oldIds.get(firstIndex - 1) !== TIMELINE_GAP)) { + insertedIds = insertedIds.unshift(TIMELINE_GAP); } return oldIds.take(firstIndex).concat( @@ -178,7 +183,7 @@ const reconnectTimeline = (state, usePendingItems) => { } return state.withMutations(mMap => { - mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items); + mMap.update(usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items); mMap.set('online', true); }); }; @@ -213,7 +218,7 @@ export default function timelines(state = initialState, action) { return state.update( action.timeline, initialTimeline, - map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items), + map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items), ); case TIMELINE_MARK_AS_PARTIAL: return state.update( @@ -221,6 +226,18 @@ export default function timelines(state = initialState, action) { initialTimeline, map => map.set('isPartial', true).set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('unread', 0), ); + case TIMELINE_INSERT: + return state.update( + action.timeline, + initialTimeline, + map => map.update('items', ImmutableList(), list => { + if (!list.includes(action.key)) { + return list.insert(action.index, action.key); + } + + return list; + }) + ); default: return state; } diff --git a/app/javascript/material-icons/400-24px/navigate_before-fill.svg b/app/javascript/material-icons/400-24px/navigate_before-fill.svg new file mode 100644 index 00000000000000..53783746ae6c81 --- /dev/null +++ b/app/javascript/material-icons/400-24px/navigate_before-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/navigate_before.svg b/app/javascript/material-icons/400-24px/navigate_before.svg new file mode 100644 index 00000000000000..53783746ae6c81 --- /dev/null +++ b/app/javascript/material-icons/400-24px/navigate_before.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/navigate_next-fill.svg b/app/javascript/material-icons/400-24px/navigate_next-fill.svg new file mode 100644 index 00000000000000..41004673651a63 --- /dev/null +++ b/app/javascript/material-icons/400-24px/navigate_next-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/navigate_next.svg b/app/javascript/material-icons/400-24px/navigate_next.svg new file mode 100644 index 00000000000000..41004673651a63 --- /dev/null +++ b/app/javascript/material-icons/400-24px/navigate_next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 3c75854d9b0c52..520e91e28bbd67 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -578,3 +578,16 @@ html { .poll__option input[type='text'] { background: darken($ui-base-color, 10%); } + +.inline-follow-suggestions { + background-color: rgba($ui-highlight-color, 0.1); + border-bottom-color: rgba($ui-highlight-color, 0.3); +} + +.inline-follow-suggestions__body__scrollable__card { + background: $white; +} + +.inline-follow-suggestions__body__scroll-button__icon { + color: $white; +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5b89e7f25d843a..f70fa12a51abd4 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9459,3 +9459,199 @@ noscript { padding: 0; } } + +.inline-follow-suggestions { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 0; + border-bottom: 1px solid mix($ui-base-color, $ui-highlight-color, 75%); + background: mix($ui-base-color, $ui-highlight-color, 95%); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + + h3 { + font-size: 15px; + line-height: 22px; + font-weight: 500; + } + + &__actions { + display: flex; + align-items: center; + gap: 24px; + } + + .link-button { + font-size: 13px; + font-weight: 500; + } + } + + &__body { + position: relative; + + &__scroll-button { + position: absolute; + height: 100%; + background: transparent; + border: none; + cursor: pointer; + top: 0; + color: $primary-text-color; + + &.left { + left: 0; + } + + &.right { + right: 0; + } + + &__icon { + border-radius: 50%; + background: $ui-highlight-color; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + padding: 8px; + + .icon { + width: 24px; + height: 24px; + } + } + + &:hover, + &:focus, + &:active { + .inline-follow-suggestions__body__scroll-button__icon { + background: lighten($ui-highlight-color, 4%); + } + } + } + + &__scrollable { + display: flex; + flex-wrap: nowrap; + gap: 16px; + padding: 16px; + padding-bottom: 0; + scroll-snap-type: x mandatory; + scroll-padding: 16px; + scroll-behavior: smooth; + overflow-x: hidden; + + &__card { + background: darken($ui-base-color, 4%); + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + padding: 12px; + scroll-snap-align: start; + flex: 0 0 auto; + width: 200px; + box-sizing: border-box; + position: relative; + + a { + text-decoration: none; + } + + & > .icon-button { + position: absolute; + inset-inline-end: 8px; + top: 8px; + } + + &__avatar { + height: 48px; + display: flex; + + a { + display: flex; + text-decoration: none; + } + } + + .account__avatar { + flex-shrink: 0; + align-self: flex-end; + border: 1px solid lighten($ui-base-color, 8%); + background-color: $ui-base-color; + } + + &__text-stack { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + max-width: 100%; + + a { + max-width: 100%; + } + + &__source { + display: inline-flex; + align-items: center; + color: $dark-text-color; + gap: 4px; + overflow: hidden; + white-space: nowrap; + + > span { + overflow: hidden; + text-overflow: ellipsis; + } + + .icon { + width: 16px; + height: 16px; + } + } + } + + .display-name { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + + & > * { + max-width: 100%; + } + + &__html { + font-size: 15px; + font-weight: 500; + color: $secondary-text-color; + } + + &__account { + font-size: 14px; + color: $darker-text-color; + } + } + + .verified-badge { + font-size: 14px; + max-width: 100%; + } + + .button { + display: block; + width: 100%; + } + } + } + } +} diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 51384ef984657d..322f3e27adb604 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -154,7 +154,7 @@ def fetch_remote_original_status if object_uri.start_with?('http') return if ActivityPub::TagManager.instance.local_uri?(object_uri) - ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id]) + ActivityPub::FetchRemoteStatusService.new.call(object_uri, on_behalf_of: @account.followers.local.first, request_id: @options[:request_id]) elsif @object['url'].present? ::FetchRemoteStatusService.new.call(@object['url'], request_id: @options[:request_id]) end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index faea63e8f12c6e..9459fdd8b76972 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -19,7 +19,7 @@ def verify_actor! return unless type == 'RsaSignature2017' creator = ActivityPub::TagManager.instance.uri_to_actor(creator_uri) - creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) if creator&.public_key.blank? + creator = ActivityPub::FetchRemoteKeyService.new.call(creator_uri) if creator&.public_key.blank? return if creator.nil? diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb index 2a21d09a8bac6e..30ada50cc02932 100644 --- a/app/models/account_summary.rb +++ b/app/models/account_summary.rb @@ -15,10 +15,12 @@ class AccountSummary < ApplicationRecord has_many :follow_recommendation_suppressions, primary_key: :account_id, foreign_key: :account_id, inverse_of: false scope :safe, -> { where(sensitive: false) } - scope :localized, ->(locale) { where(language: locale) } + scope :localized, ->(locale) { order(Arel::Nodes::Case.new.when(arel_table[:language].eq(locale)).then(1).else(0).desc) } scope :filtered, -> { where.missing(:follow_recommendation_suppressions) } def self.refresh + Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) + rescue ActiveRecord::StatementInvalid Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false) end diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb index 9d2648394bc79f..6b49a3ca660170 100644 --- a/app/models/follow_recommendation.rb +++ b/app/models/follow_recommendation.rb @@ -19,6 +19,8 @@ class FollowRecommendation < ApplicationRecord scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) } def self.refresh + Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) + rescue ActiveRecord::StatementInvalid Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false) end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 567dd8a14abc04..7b083d889b21fb 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -2,7 +2,7 @@ class ActivityPub::FetchRemoteAccountService < ActivityPub::FetchRemoteActorService # Does a WebFinger roundtrip on each call, unless `only_key` is true - def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil) + def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil) actor = super return actor if actor.nil? || actor.is_a?(Account) diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb index 8df8c75876644d..86a134bb4ed911 100644 --- a/app/services/activitypub/fetch_remote_actor_service.rb +++ b/app/services/activitypub/fetch_remote_actor_service.rb @@ -10,15 +10,15 @@ class Error < StandardError; end SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze # Does a WebFinger roundtrip on each call, unless `only_key` is true - def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil) + def call(uri, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true, request_id: nil) return if domain_not_allowed?(uri) return ActivityPub::TagManager.instance.uri_to_actor(uri) if ActivityPub::TagManager.instance.local_uri?(uri) @json = begin if prefetched_body.nil? - fetch_resource(uri, id) + fetch_resource(uri, true) else - body_to_json(prefetched_body, compare_id: id ? uri : nil) + body_to_json(prefetched_body, compare_id: uri) end rescue Oj::ParseError raise Error, "Error parsing JSON-LD document #{uri}" diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index 8eb97c1e66d188..e96b5ad3bb012c 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -6,23 +6,10 @@ class ActivityPub::FetchRemoteKeyService < BaseService class Error < StandardError; end # Returns actor that owns the key - def call(uri, id: true, prefetched_body: nil, suppress_errors: true) + def call(uri, suppress_errors: true) raise Error, 'No key URI given' if uri.blank? - if prefetched_body.nil? - if id - @json = fetch_resource_without_id_validation(uri) - if actor_type? - @json = fetch_resource(@json['id'], true) - elsif uri != @json['id'] - raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}" - end - else - @json = fetch_resource(uri, id) - end - else - @json = body_to_json(prefetched_body, compare_id: id ? uri : nil) - end + @json = fetch_resource(uri, false) raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index e3a9b60b5679f2..6f8882378f32ec 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -8,14 +8,14 @@ class ActivityPub::FetchRemoteStatusService < BaseService DISCOVERIES_PER_REQUEST = 1000 # Should be called when uri has already been checked for locality - def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil) + def call(uri, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil) return if domain_not_allowed?(uri) @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}" @json = if prefetched_body.nil? - fetch_resource(uri, id, on_behalf_of) + fetch_resource(uri, true, on_behalf_of) else - body_to_json(prefetched_body, compare_id: id ? uri : nil) + body_to_json(prefetched_body, compare_id: uri) end return unless supported_context? @@ -65,7 +65,7 @@ def trustworthy_attribution?(uri, attributed_to) def account_from_uri(uri) actor = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(uri, id: true, request_id: @request_id) if actor.nil? || actor.possibly_stale? + actor = ActivityPub::FetchRemoteAccountService.new.call(uri, request_id: @request_id) if actor.nil? || actor.possibly_stale? actor end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 8fc0989a3f7e91..9e787ace508a0d 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -277,7 +277,7 @@ def collection_info(type) def moved_account account = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account) - account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true, break_on_redirect: true, request_id: @options[:request_id]) + account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], break_on_redirect: true, request_id: @options[:request_id]) account end diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb index a3406e5a579c57..71c6cca790c6ee 100644 --- a/app/services/fetch_resource_service.rb +++ b/app/services/fetch_resource_service.rb @@ -48,7 +48,15 @@ def process_response(response, terminal = false) body = response.body_with_limit json = body_to_json(body) - [json['id'], { prefetched_body: body, id: true }] if supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json)) + return unless supported_context?(json) && (equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteActorService::SUPPORTED_TYPES) || expected_type?(json)) + + if json['id'] != @url + return if terminal + + return process(json['id'], terminal: true) + end + + [@url, { prefetched_body: body }] elsif !terminal link_header = response['Link'] && parse_link_header(response) diff --git a/config/routes.rb b/config/routes.rb index c4f862acafa51f..bb088821fd7356 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -104,12 +104,12 @@ def redirect_with_vary(path) confirmations: 'auth/confirmations', } - # rubocop:disable Style/FormatStringToken - those do not go through the usual formatting functions and are not safe to correct - get '/users/:username', to: redirect_with_vary('/@%{username}'), constraints: ->(req) { req.format.nil? || req.format.html? } - get '/users/:username/following', to: redirect_with_vary('/@%{username}/following'), constraints: ->(req) { req.format.nil? || req.format.html? } - get '/users/:username/followers', to: redirect_with_vary('/@%{username}/followers'), constraints: ->(req) { req.format.nil? || req.format.html? } - get '/users/:username/statuses/:id', to: redirect_with_vary('/@%{username}/%{id}'), constraints: ->(req) { req.format.nil? || req.format.html? } - # rubocop:enable Style/FormatStringToken + with_options constraints: ->(req) { req.format.nil? || req.format.html? } do + get '/users/:username', to: redirect_with_vary('/@%{username}') + get '/users/:username/following', to: redirect_with_vary('/@%{username}/following') + get '/users/:username/followers', to: redirect_with_vary('/@%{username}/followers') + get '/users/:username/statuses/:id', to: redirect_with_vary('/@%{username}/%{id}') + end get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } diff --git a/db/migrate/20210322164601_create_account_summaries.rb b/db/migrate/20210322164601_create_account_summaries.rb index 8d18e9eeb4bd34..5c93291e0a54ee 100644 --- a/db/migrate/20210322164601_create_account_summaries.rb +++ b/db/migrate/20210322164601_create_account_summaries.rb @@ -2,7 +2,7 @@ class CreateAccountSummaries < ActiveRecord::Migration[5.2] def change - create_view :account_summaries, materialized: { no_data: true } + create_view :account_summaries, materialized: true # To be able to refresh the view concurrently, # at least one unique index is required diff --git a/db/migrate/20210505174616_update_follow_recommendations_to_version_2.rb b/db/migrate/20210505174616_update_follow_recommendations_to_version_2.rb index 22c27a0e7a8e84..3a9024ffcb1d12 100644 --- a/db/migrate/20210505174616_update_follow_recommendations_to_version_2.rb +++ b/db/migrate/20210505174616_update_follow_recommendations_to_version_2.rb @@ -6,7 +6,7 @@ class UpdateFollowRecommendationsToVersion2 < ActiveRecord::Migration[6.1] def up drop_view :follow_recommendations - create_view :follow_recommendations, version: 2, materialized: { no_data: true } + create_view :follow_recommendations, version: 2, materialized: true # To be able to refresh the view concurrently, # at least one unique index is required diff --git a/db/migrate/20211213040746_update_account_summaries_to_version_2.rb b/db/migrate/20211213040746_update_account_summaries_to_version_2.rb index e347a874ff5181..a82cf4afa2eef3 100644 --- a/db/migrate/20211213040746_update_account_summaries_to_version_2.rb +++ b/db/migrate/20211213040746_update_account_summaries_to_version_2.rb @@ -4,7 +4,7 @@ class UpdateAccountSummariesToVersion2 < ActiveRecord::Migration[6.1] def up reapplication_follow_recommendations_v2 do drop_view :account_summaries, materialized: true - create_view :account_summaries, version: 2, materialized: { no_data: true } + create_view :account_summaries, version: 2, materialized: true safety_assured { add_index :account_summaries, :account_id, unique: true } end end @@ -12,7 +12,7 @@ def up def down reapplication_follow_recommendations_v2 do drop_view :account_summaries, materialized: true - create_view :account_summaries, version: 1, materialized: { no_data: true } + create_view :account_summaries, version: 1, materialized: true safety_assured { add_index :account_summaries, :account_id, unique: true } end end @@ -20,7 +20,7 @@ def down def reapplication_follow_recommendations_v2 drop_view :follow_recommendations, materialized: true yield - create_view :follow_recommendations, version: 2, materialized: { no_data: true } + create_view :follow_recommendations, version: 2, materialized: true safety_assured { add_index :follow_recommendations, :account_id, unique: true } end end diff --git a/db/migrate/20230818141056_create_global_follow_recommendations.rb b/db/migrate/20230818141056_create_global_follow_recommendations.rb index b88c71b9d7ab0d..1a3f23d228f76c 100644 --- a/db/migrate/20230818141056_create_global_follow_recommendations.rb +++ b/db/migrate/20230818141056_create_global_follow_recommendations.rb @@ -2,7 +2,7 @@ class CreateGlobalFollowRecommendations < ActiveRecord::Migration[7.0] def change - create_view :global_follow_recommendations, materialized: { no_data: true } + create_view :global_follow_recommendations, materialized: true safety_assured { add_index :global_follow_recommendations, :account_id, unique: true } end end diff --git a/db/post_migrate/20230818142253_drop_follow_recommendations.rb b/db/post_migrate/20230818142253_drop_follow_recommendations.rb index 95913d6caa792f..e0a24753cad563 100644 --- a/db/post_migrate/20230818142253_drop_follow_recommendations.rb +++ b/db/post_migrate/20230818142253_drop_follow_recommendations.rb @@ -6,7 +6,7 @@ def up end def down - create_view :follow_recommendations, version: 2, materialized: { no_data: true } + create_view :follow_recommendations, version: 2, materialized: true safety_assured { add_index :follow_recommendations, :account_id, unique: true } end end diff --git a/lib/mastodon/cli/media.rb b/lib/mastodon/cli/media.rb index 87946e0262f35b..ac8f219807284c 100644 --- a/lib/mastodon/cli/media.rb +++ b/lib/mastodon/cli/media.rb @@ -58,7 +58,7 @@ def remove end unless options[:prune_profiles] || options[:remove_headers] - processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where(created_at: ..time_ago)) do |media_attachment| + processed, aggregate = parallelize_with_progress(MediaAttachment.cached.remote.where(created_at: ..time_ago)) do |media_attachment| next if media_attachment.file.blank? size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0) diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 587e89303bd1f2..dd7c84207e0b1e 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -17,7 +17,7 @@ def patch end def default_prerelease - 'alpha.0' + 'alpha.1' end def prerelease diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb index e3295204aaf393..6eb970dedbba59 100644 --- a/spec/controllers/concerns/account_controller_concern_spec.rb +++ b/spec/controllers/concerns/account_controller_concern_spec.rb @@ -49,22 +49,21 @@ def success end context 'when account is not suspended' do - it 'assigns @account' do - account = Fabricate(:account) + let(:account) { Fabricate(:account, username: 'username') } + + it 'assigns @account, returns success, and sets link headers' do get 'success', params: { account_username: account.username } - expect(assigns(:account)).to eq account - end - it 'sets link headers' do - Fabricate(:account, username: 'username') - get 'success', params: { account_username: 'username' } - expect(response.headers['Link'].to_s).to eq '; rel="lrdd"; type="application/jrd+json", ; rel="alternate"; type="application/activity+json"' + expect(assigns(:account)).to eq account + expect(response).to have_http_status(200) + expect(response.headers['Link'].to_s).to eq(expected_link_headers) end - it 'returns http success' do - account = Fabricate(:account) - get 'success', params: { account_username: account.username } - expect(response).to have_http_status(200) + def expected_link_headers + [ + '; rel="lrdd"; type="application/jrd+json"', + '; rel="alternate"; type="application/activity+json"', + ].join(', ') end end end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 97268eea6d04b2..1af45673c049bc 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -56,7 +56,7 @@ allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) - allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do + allow(service_stub).to receive(:call).with('http://example.com/alice') do sender.update!(public_key: old_key) sender end @@ -64,7 +64,7 @@ it 'fetches key and returns creator' do expect(subject.verify_actor!).to eq sender - expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once + expect(service_stub).to have_received(:call).with('http://example.com/alice').once end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index 4015723f6df9cf..e7f6bb8dd83401 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -18,7 +18,7 @@ end describe '#call' do - let(:account) { subject.call('https://example.com/alice', id: true) } + let(:account) { subject.call('https://example.com/alice') } shared_examples 'sets profile data' do it 'returns an account with expected details' do diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb index 485ca81a11aaa9..e622c7d4c30d84 100644 --- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb @@ -18,7 +18,7 @@ end describe '#call' do - let(:account) { subject.call('https://example.com/alice', id: true) } + let(:account) { subject.call('https://example.com/alice') } shared_examples 'sets profile data' do it 'returns an account and sets attributes' do diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index e210d20ec77d40..0b14da4f446e5f 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -55,7 +55,7 @@ end describe '#call' do - let(:account) { subject.call(public_key_id, id: false) } + let(:account) { subject.call(public_key_id) } context 'when the key is a sub-object from the actor' do before do diff --git a/spec/services/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb index 0f1068471f8e38..78037a06ce4fdb 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -57,7 +57,7 @@ let(:json) do { - id: 1, + id: 'http://example.com/foo', '@context': ActivityPub::TagManager::CONTEXT, type: 'Note', }.to_json @@ -83,27 +83,27 @@ let(:content_type) { 'application/activity+json; charset=utf-8' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when content type is ld+json with profile' do let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when link header is present' do let(:headers) { { 'Link' => '; rel="alternate"; type="application/activity+json"' } } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end context 'when content type is text/html' do let(:content_type) { 'text/html' } let(:body) { '' } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end end end diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index bcfb9dbfb0f893..5270cc10dd8f12 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -139,6 +139,7 @@ stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url }) body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) + stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) end it 'returns status by url' do diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index d4f27e209e11a6..4aba65b4047a4f 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -14,6 +14,7 @@ options = Selenium::WebDriver::Chrome::Options.new options.add_argument '--headless=new' options.add_argument '--window-size=1680,1050' + options.browser_version = '120' Capybara::Selenium::Driver.new( app, diff --git a/spec/support/stories/profile_stories.rb b/spec/support/stories/profile_stories.rb index 2b345ddef10b31..74342c337d6688 100644 --- a/spec/support/stories/profile_stories.rb +++ b/spec/support/stories/profile_stories.rb @@ -10,7 +10,7 @@ def as_a_registered_user account: Fabricate(:account, username: 'bob') ) - Web::Setting.where(user: bob).first_or_initialize(user: bob).update!(data: { introductionVersion: 201812160442020 }) if finished_onboarding # rubocop:disable Style/NumericLiterals + Web::Setting.where(user: bob).first_or_initialize(user: bob).update!(data: { introductionVersion: 2018_12_16_044202 }) if finished_onboarding end def as_a_logged_in_user diff --git a/yarn.lock b/yarn.lock index 3f70a02e74803f..4d1084c229bf91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1777,8 +1777,8 @@ __metadata: linkType: hard "@formatjs/cli@npm:^6.1.1": - version: 6.2.6 - resolution: "@formatjs/cli@npm:6.2.6" + version: 6.2.7 + resolution: "@formatjs/cli@npm:6.2.7" peerDependencies: vue: ^3.3.4 peerDependenciesMeta: @@ -1786,17 +1786,7 @@ __metadata: optional: true bin: formatjs: bin/formatjs - checksum: f8b0bc45c72b83437f0dc91a2d3ea513852c11bfd8eedbc2f255b19552f153bccb4d38fcd281f897ca60d0dfddf2b99de22e5a87cb1e173ca11df88c61cde2e4 - languageName: node - linkType: hard - -"@formatjs/ecma402-abstract@npm:1.18.0": - version: 1.18.0 - resolution: "@formatjs/ecma402-abstract@npm:1.18.0" - dependencies: - "@formatjs/intl-localematcher": "npm:0.5.2" - tslib: "npm:^2.4.0" - checksum: bbdad0aee8e48baad6bfe6b2c27caf3befe35e658b922ee2f84417a819f0bdc7e849a8c0c782db8b53f5666bf19669d2b10a1104257c08796d198c87766bfc92 + checksum: ee7b0873a734e02721ce1ee107ee60845bb30855f4ca686bfb6c5e9862353249d5d20748b18db93200aabc7a59875ff062f485c64d41cb8e61f1d43e2bb5eceb languageName: node linkType: hard @@ -1819,17 +1809,6 @@ __metadata: languageName: node linkType: hard -"@formatjs/icu-messageformat-parser@npm:2.7.3": - version: 2.7.3 - resolution: "@formatjs/icu-messageformat-parser@npm:2.7.3" - dependencies: - "@formatjs/ecma402-abstract": "npm:1.18.0" - "@formatjs/icu-skeleton-parser": "npm:1.7.0" - tslib: "npm:^2.4.0" - checksum: 2a51038813e5cff7e2df767e1227373d228e907adb7268fc3744b3d82c4fa69d4aa9f6020a62de2c468cf724600e9372ac07ae43a4480ed066fe34e224e80e4a - languageName: node - linkType: hard - "@formatjs/icu-messageformat-parser@npm:2.7.6": version: 2.7.6 resolution: "@formatjs/icu-messageformat-parser@npm:2.7.6" @@ -1841,16 +1820,6 @@ __metadata: languageName: node linkType: hard -"@formatjs/icu-skeleton-parser@npm:1.7.0": - version: 1.7.0 - resolution: "@formatjs/icu-skeleton-parser@npm:1.7.0" - dependencies: - "@formatjs/ecma402-abstract": "npm:1.18.0" - tslib: "npm:^2.4.0" - checksum: 2e4db815247ddb10f7990bbb501c85b854ee951ee45143673eb91b4392b11d0a8312327adb8b624c6a2fdafab12083904630d6d22475503d025f1612da4dcaee - languageName: node - linkType: hard - "@formatjs/icu-skeleton-parser@npm:1.8.0": version: 1.8.0 resolution: "@formatjs/icu-skeleton-parser@npm:1.8.0" @@ -1883,15 +1852,6 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.5.2": - version: 0.5.2 - resolution: "@formatjs/intl-localematcher@npm:0.5.2" - dependencies: - tslib: "npm:^2.4.0" - checksum: 4b3ae75470e3e53ffa39b2d46e65a2a4c9c4becbc0aac989b0694370e10c6687643660a045512d676509bc29b257fe5726fbb028de12f889be02c2d20b6527e6 - languageName: node - linkType: hard - "@formatjs/intl-localematcher@npm:0.5.4": version: 0.5.4 resolution: "@formatjs/intl-localematcher@npm:0.5.4" @@ -1952,26 +1912,6 @@ __metadata: languageName: node linkType: hard -"@formatjs/ts-transformer@npm:3.13.9": - version: 3.13.9 - resolution: "@formatjs/ts-transformer@npm:3.13.9" - dependencies: - "@formatjs/icu-messageformat-parser": "npm:2.7.3" - "@types/json-stable-stringify": "npm:^1.0.32" - "@types/node": "npm:14 || 16 || 17" - chalk: "npm:^4.0.0" - json-stable-stringify: "npm:^1.0.1" - tslib: "npm:^2.4.0" - typescript: "npm:5" - peerDependencies: - ts-jest: ">=27" - peerDependenciesMeta: - ts-jest: - optional: true - checksum: 4e313b967e45aae79246174c3181d31cc7cd297380d3a880a98fc0be16d76b783868712151e840ea16d22e2fbec0388b1005f688b6d4cb74ee4411b43f6d33f4 - languageName: node - linkType: hard - "@gamestdio/websocket@npm:^0.3.2": version: 0.3.2 resolution: "@gamestdio/websocket@npm:0.3.2" @@ -2945,8 +2885,8 @@ __metadata: linkType: hard "@testing-library/jest-dom@npm:^6.0.0": - version: 6.2.0 - resolution: "@testing-library/jest-dom@npm:6.2.0" + version: 6.4.0 + resolution: "@testing-library/jest-dom@npm:6.4.0" dependencies: "@adobe/css-tools": "npm:^4.3.2" "@babel/runtime": "npm:^7.9.2" @@ -2958,19 +2898,22 @@ __metadata: redent: "npm:^3.0.0" peerDependencies: "@jest/globals": ">= 28" + "@types/bun": "*" "@types/jest": ">= 28" jest: ">= 28" vitest: ">= 0.32" peerDependenciesMeta: "@jest/globals": optional: true + "@types/bun": + optional: true "@types/jest": optional: true jest: optional: true vitest: optional: true - checksum: 71421693e0ad6a46be7d16f00b58a45725c238693972b8b5b1fd9ab797902ccf1209cf259afe8da1bf59d7c958762c46ee85d1aa5b164a5ec330981ea2376b08 + checksum: 6b7eba9ca388986a721fb12f84adf0f5534bf7ec5851982023a889c4a0afac6e9e91291bdac39e1f59a05adefd7727e30463d98b21c3da32fbfec229ccb11ef1 languageName: node linkType: hard @@ -3720,14 +3663,14 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^6.0.0": - version: 6.19.0 - resolution: "@typescript-eslint/eslint-plugin@npm:6.19.0" + version: 6.20.0 + resolution: "@typescript-eslint/eslint-plugin@npm:6.20.0" dependencies: "@eslint-community/regexpp": "npm:^4.5.1" - "@typescript-eslint/scope-manager": "npm:6.19.0" - "@typescript-eslint/type-utils": "npm:6.19.0" - "@typescript-eslint/utils": "npm:6.19.0" - "@typescript-eslint/visitor-keys": "npm:6.19.0" + "@typescript-eslint/scope-manager": "npm:6.20.0" + "@typescript-eslint/type-utils": "npm:6.20.0" + "@typescript-eslint/utils": "npm:6.20.0" + "@typescript-eslint/visitor-keys": "npm:6.20.0" debug: "npm:^4.3.4" graphemer: "npm:^1.4.0" ignore: "npm:^5.2.4" @@ -3740,44 +3683,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: ab1a5ace6663b0c6d2418e321328fa28aa4bdc4b5fae257addec01346fb3a9c2d3a2960ade0f7114e6974c513a28632c9e8e602333cc0fab3135c445babdef59 + checksum: 5020faac39be476de056342f58f2bf68bb788f230e2fa4a2e27ceab8a5187dc450beba7333b0aa741a43aeaff45a117558132953f9390b5eca4c2cc004fde716 languageName: node linkType: hard "@typescript-eslint/parser@npm:^6.17.0": - version: 6.19.0 - resolution: "@typescript-eslint/parser@npm:6.19.0" + version: 6.20.0 + resolution: "@typescript-eslint/parser@npm:6.20.0" dependencies: - "@typescript-eslint/scope-manager": "npm:6.19.0" - "@typescript-eslint/types": "npm:6.19.0" - "@typescript-eslint/typescript-estree": "npm:6.19.0" - "@typescript-eslint/visitor-keys": "npm:6.19.0" + "@typescript-eslint/scope-manager": "npm:6.20.0" + "@typescript-eslint/types": "npm:6.20.0" + "@typescript-eslint/typescript-estree": "npm:6.20.0" + "@typescript-eslint/visitor-keys": "npm:6.20.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: d547bfb1aaed112cfc0f9f0be8506a280952ba3b61be42b749352139361bd94e4a47fa043d819e19c6a498cacbd8bb36a46e3628c436a7e2009e7ac27afc8861 + checksum: d84ad5e2282b1096c80dedb903c83ecc31eaf7be1aafcb14c18d9ec2d4a319f2fd1e5a9038b944d9f42c36c1c57add5e4292d4026ca7d3d5441d41286700d402 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/scope-manager@npm:6.19.0" +"@typescript-eslint/scope-manager@npm:6.20.0": + version: 6.20.0 + resolution: "@typescript-eslint/scope-manager@npm:6.20.0" dependencies: - "@typescript-eslint/types": "npm:6.19.0" - "@typescript-eslint/visitor-keys": "npm:6.19.0" - checksum: 1ec7b9dedca7975f0aa4543c1c382f7d6131411bd443a5f9b96f137acb6adb450888ed13c95f6d26546b682b2e0579ce8a1c883fdbe2255dc0b61052193b8243 + "@typescript-eslint/types": "npm:6.20.0" + "@typescript-eslint/visitor-keys": "npm:6.20.0" + checksum: f6768ed2dcd2d1771d55ed567ff392a6569ffd683a26500067509dd41769f8838c43686460fe7337144f324fd063df33f5d5646d44e5df4998ceffb3ad1fb790 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/type-utils@npm:6.19.0" +"@typescript-eslint/type-utils@npm:6.20.0": + version: 6.20.0 + resolution: "@typescript-eslint/type-utils@npm:6.20.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:6.19.0" - "@typescript-eslint/utils": "npm:6.19.0" + "@typescript-eslint/typescript-estree": "npm:6.20.0" + "@typescript-eslint/utils": "npm:6.20.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.0.1" peerDependencies: @@ -3785,23 +3728,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 5b146b985481e587122026c703ac9f537ad7e90eee1dca814971bca0d7e4a5d4ff9861fb4bf749014c28c6a4fbb4a01a4527355961315eb9501f3569f8e8dd38 + checksum: 8f622fbb14268f1d00b2948f995b570f0ef82be02c12be41d90385290a56ea0dbd34d855d6a5aff100b57f3bdd300ff0c300f16c78f12d6064f7ae6e34fd71bf languageName: node linkType: hard -"@typescript-eslint/types@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/types@npm:6.19.0" - checksum: 6f81860a3c14df55232c2e6dec21fb166867b9f30b3c3369b325aef5ee1c7e41e827c0504654daa49c8ff1a3a9ca9d9bfe76786882b6212a7c1b58991a9c80b9 +"@typescript-eslint/types@npm:6.20.0": + version: 6.20.0 + resolution: "@typescript-eslint/types@npm:6.20.0" + checksum: 37589003b0e06f83c1945e3748e91af85918cfd997766894642a08e6f355f611cfe11df4e7632dda96e3a9b3441406283fe834ab0906cf81ea97fd43ca2aebe3 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/typescript-estree@npm:6.19.0" +"@typescript-eslint/typescript-estree@npm:6.20.0": + version: 6.20.0 + resolution: "@typescript-eslint/typescript-estree@npm:6.20.0" dependencies: - "@typescript-eslint/types": "npm:6.19.0" - "@typescript-eslint/visitor-keys": "npm:6.19.0" + "@typescript-eslint/types": "npm:6.20.0" + "@typescript-eslint/visitor-keys": "npm:6.20.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -3811,34 +3754,34 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 5b365f009e43c7beafdbb7d8ecad78ee1087b0a4338cd9ec695eed514b7b4c1089e56239761139ddae629ec0ce8d428840c6ebfeea3618d2efe00c84f8794da5 + checksum: 551f13445a303882d9fc0fbe14ef8507eb8414253fd87a5f13d2e324b5280b626421a238b8ec038e628bc80128dc06c057757f668738e82e64d5b39a9083c27d languageName: node linkType: hard -"@typescript-eslint/utils@npm:6.19.0, @typescript-eslint/utils@npm:^6.5.0": - version: 6.19.0 - resolution: "@typescript-eslint/utils@npm:6.19.0" +"@typescript-eslint/utils@npm:6.20.0, @typescript-eslint/utils@npm:^6.18.1": + version: 6.20.0 + resolution: "@typescript-eslint/utils@npm:6.20.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" "@types/json-schema": "npm:^7.0.12" "@types/semver": "npm:^7.5.0" - "@typescript-eslint/scope-manager": "npm:6.19.0" - "@typescript-eslint/types": "npm:6.19.0" - "@typescript-eslint/typescript-estree": "npm:6.19.0" + "@typescript-eslint/scope-manager": "npm:6.20.0" + "@typescript-eslint/types": "npm:6.20.0" + "@typescript-eslint/typescript-estree": "npm:6.20.0" semver: "npm:^7.5.4" peerDependencies: eslint: ^7.0.0 || ^8.0.0 - checksum: 343ff4cd4f7e102df8c46b41254d017a33d95df76455531fda679fdb92aebb9c111df8ee9ab54972e73c1e8fad9dd7e421001233f0aee8115384462b0821852e + checksum: 0a8ede3d80a365b52ae96d88e4a9f6e6abf3569c6b60ff9f42ff900cd843ae7c5493cd95f8f2029d90bb0acbf31030980206af98e581d760d6d41e0f80e9fb86 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:6.19.0": - version: 6.19.0 - resolution: "@typescript-eslint/visitor-keys@npm:6.19.0" +"@typescript-eslint/visitor-keys@npm:6.20.0": + version: 6.20.0 + resolution: "@typescript-eslint/visitor-keys@npm:6.20.0" dependencies: - "@typescript-eslint/types": "npm:6.19.0" + "@typescript-eslint/types": "npm:6.20.0" eslint-visitor-keys: "npm:^3.4.1" - checksum: bb34e922e018aadf34866995ea5949d6623f184cc4f6470ab05767dd208ffabb003b7dc3872199714574b7f10afe89d49c6f89a4e8d086edea82be73e189f1bb + checksum: 852d938f2e5d57200cf62733b42e73a369f797b097d17e8fd3fffd0f7315c3b9e1863eed60bb8d57d6535a3b7f1980f645f96ec6d513950f182bfa8107b33fab languageName: node linkType: hard @@ -7356,23 +7299,23 @@ __metadata: linkType: hard "eslint-plugin-formatjs@npm:^4.10.1": - version: 4.11.3 - resolution: "eslint-plugin-formatjs@npm:4.11.3" + version: 4.12.2 + resolution: "eslint-plugin-formatjs@npm:4.12.2" dependencies: - "@formatjs/icu-messageformat-parser": "npm:2.7.3" - "@formatjs/ts-transformer": "npm:3.13.9" + "@formatjs/icu-messageformat-parser": "npm:2.7.6" + "@formatjs/ts-transformer": "npm:3.13.12" "@types/eslint": "npm:7 || 8" "@types/picomatch": "npm:^2.3.0" - "@typescript-eslint/utils": "npm:^6.5.0" + "@typescript-eslint/utils": "npm:^6.18.1" emoji-regex: "npm:^10.2.1" magic-string: "npm:^0.30.0" picomatch: "npm:^2.3.1" tslib: "npm:2.6.2" typescript: "npm:5" - unicode-emoji-utils: "npm:^1.1.1" + unicode-emoji-utils: "npm:^1.2.0" peerDependencies: eslint: 7 || 8 - checksum: 66481e0b5af5738bdb2b164ac1c74216c5c26f7c7400456a58387e71424bb30554aef39da43ce29bfd551f7dad678818d9af029022edadc4e1024349339f6984 + checksum: 77cc1a2959903fcb6639d9fec89e7dfc55cf1e4ea58fca7d3bd6d12fa540aa173cbf5f90fc629b6aaf2ea3b8e61ed0a3cfce940fd2bec6f0796353315e2dbeef languageName: node linkType: hard @@ -7404,8 +7347,8 @@ __metadata: linkType: hard "eslint-plugin-jsdoc@npm:^48.0.0": - version: 48.0.2 - resolution: "eslint-plugin-jsdoc@npm:48.0.2" + version: 48.0.4 + resolution: "eslint-plugin-jsdoc@npm:48.0.4" dependencies: "@es-joy/jsdoccomment": "npm:~0.41.0" are-docs-informative: "npm:^0.0.2" @@ -7418,7 +7361,7 @@ __metadata: spdx-expression-parse: "npm:^4.0.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 6e6062c22fa4039e4be898a62f8ca0edef8bcbdc8257abb18302471e9819ccd63941971cf8de0ccf4eb59b3508902aa06de56214d80bdfc9bde7cadb94906190 + checksum: c73063d26ca70d37ea00eea9750d1f889e5bfda64ca46dbfc6bf4842b892551c320368220cb46acc9d3d96a89fd5391486650284b82dc722f700e3b5df5c78db languageName: node linkType: hard @@ -9123,9 +9066,9 @@ __metadata: linkType: hard "immutable@npm:^4.0.0, immutable@npm:^4.0.0-rc.1, immutable@npm:^4.3.0": - version: 4.3.4 - resolution: "immutable@npm:4.3.4" - checksum: c15b9f0fa7b3c9315725cb00704fddad59f0e668a7379c39b9a528a8386140ee9effb015ae51a5b423e05c59d15fc0b38c970db6964ad6b3e05d0761db68441f + version: 4.3.5 + resolution: "immutable@npm:4.3.5" + checksum: 63d2d7908241a955d18c7822fd2215b6e89ff5a1a33cc72cd475b013cbbdef7a705aa5170a51ce9f84a57f62fdddfaa34e7b5a14b33d8a43c65cc6a881d6e894 languageName: node linkType: hard @@ -16507,7 +16450,7 @@ __metadata: languageName: node linkType: hard -"unicode-emoji-utils@npm:^1.1.1": +"unicode-emoji-utils@npm:^1.2.0": version: 1.2.0 resolution: "unicode-emoji-utils@npm:1.2.0" dependencies: