From 022d2a3793bf45631be938e9e803f81b5ff2b4b5 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 31 Jan 2024 07:52:51 -0500 Subject: [PATCH 1/8] Make factory gems available in test+development envs (#28969) --- Gemfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 4951304e3..906441ec6 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 From 738dba0cf7380c0392d2588deeddbdaa197a6331 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 31 Jan 2024 13:55:15 +0100 Subject: [PATCH 2/8] Update dependency capybara to v3.40.0 (#28966) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3891139dc..01f5b4592 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) From dd934ebb07b1dc087fb782c025935de8e1107367 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 31 Jan 2024 11:55:50 -0500 Subject: [PATCH 3/8] Update `actions/cache` to v4 (updates node 16->20) (#29025) --- .github/actions/setup-javascript/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml index 07fd4d08d..808adc7de 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 }} From 812a13142371441fb4510a0d8862ee413f3b98c5 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 1 Feb 2024 10:33:12 +0100 Subject: [PATCH 4/8] Add github action workflow for manual security builds (#29040) --- .github/workflows/build-security.yml | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/build-security.yml diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml new file mode 100644 index 000000000..cc9bae922 --- /dev/null +++ b/.github/workflows/build-security.yml @@ -0,0 +1,62 @@ +name: Build security nightly container image + +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 From 8b7b0ee59873fcb6ad79daabd46eec07ae2c68ee Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 1 Feb 2024 04:46:31 -0500 Subject: [PATCH 5/8] Configure selenium to use Chrome version 120 (#29038) --- spec/support/capybara.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index d4f27e209..4aba65b40 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -14,6 +14,7 @@ Capybara.register_driver :headless_chrome do |app| 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, From 7316a08380faed6c3553d0d24f51d206fe974e92 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 1 Feb 2024 10:52:01 +0100 Subject: [PATCH 6/8] Fix missing `workflow_dispatch` trigger for `build-security` (#29041) --- .github/workflows/build-security.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-security.yml b/.github/workflows/build-security.yml index cc9bae922..b03b787d5 100644 --- a/.github/workflows/build-security.yml +++ b/.github/workflows/build-security.yml @@ -1,4 +1,6 @@ name: Build security nightly container image +on: + workflow_dispatch: permissions: contents: read From 9cdc60ecc6e5746b706bdcf19d0743d1c153105f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 1 Feb 2024 14:37:04 +0100 Subject: [PATCH 7/8] Change onboarding prompt to follow suggestions carousel in web UI (#28878) --- .../mastodon/actions/suggestions.js | 9 +- app/javascript/mastodon/actions/timelines.js | 21 ++ .../mastodon/components/status_list.jsx | 53 +++-- .../components/explore_prompt.tsx | 46 ---- .../components/inline_follow_suggestions.jsx | 201 ++++++++++++++++++ .../mastodon/features/home_timeline/index.jsx | 51 +---- .../ui/containers/status_list_container.js | 2 +- app/javascript/mastodon/locales/en.json | 10 +- app/javascript/mastodon/reducers/settings.js | 2 +- .../mastodon/reducers/suggestions.js | 6 +- app/javascript/mastodon/reducers/timelines.js | 31 ++- .../400-24px/navigate_before-fill.svg | 1 + .../400-24px/navigate_before.svg | 1 + .../400-24px/navigate_next-fill.svg | 1 + .../material-icons/400-24px/navigate_next.svg | 1 + .../styles/mastodon-light/diff.scss | 13 ++ .../styles/mastodon/components.scss | 196 +++++++++++++++++ 17 files changed, 507 insertions(+), 138 deletions(-) delete mode 100644 app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx create mode 100644 app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.jsx create mode 100644 app/javascript/material-icons/400-24px/navigate_before-fill.svg create mode 100644 app/javascript/material-icons/400-24px/navigate_before.svg create mode 100644 app/javascript/material-icons/400-24px/navigate_next-fill.svg create mode 100644 app/javascript/material-icons/400-24px/navigate_next.svg diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js index 870a31102..8eafe38b2 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 08561c71f..4ce7c3cf8 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 e92dd233e..3ed20f65e 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/home_timeline/components/explore_prompt.tsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx deleted file mode 100644 index 960d30e2c..000000000 --- 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 000000000..ac414d04d --- /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 069f52b0b..6e7dc2b6c 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 22a831b09..12d0068d6 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -277,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", @@ -303,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 a605ecbb8..0e353e0d1 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 0f224ff4b..5b9d983de 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 43dedd6e6..4c9ab98a8 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 000000000..53783746a --- /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 000000000..53783746a --- /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 000000000..410046736 --- /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 000000000..410046736 --- /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 3c75854d9..520e91e28 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 5b89e7f25..f70fa12a5 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%; + } + } + } + } +} From 1726085db5cd73dd30953da858f9887bcc90b958 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 1 Feb 2024 15:56:46 +0100 Subject: [PATCH 8/8] Merge pull request from GHSA-3fjr-858r-92rw * Fix insufficient origin validation * Bump version to 4.3.0-alpha.1 --- .../concerns/signature_verification.rb | 2 +- app/helpers/jsonld_helper.rb | 4 ++-- app/lib/activitypub/activity.rb | 2 +- app/lib/activitypub/linked_data_signature.rb | 2 +- .../activitypub/fetch_remote_account_service.rb | 2 +- .../activitypub/fetch_remote_actor_service.rb | 6 +++--- .../activitypub/fetch_remote_key_service.rb | 17 ++--------------- .../activitypub/fetch_remote_status_service.rb | 8 ++++---- .../activitypub/process_account_service.rb | 2 +- app/services/fetch_resource_service.rb | 10 +++++++++- lib/mastodon/version.rb | 2 +- .../activitypub/linked_data_signature_spec.rb | 4 ++-- .../fetch_remote_account_service_spec.rb | 2 +- .../fetch_remote_actor_service_spec.rb | 2 +- .../fetch_remote_key_service_spec.rb | 2 +- spec/services/fetch_resource_service_spec.rb | 10 +++++----- spec/services/resolve_url_service_spec.rb | 1 + 17 files changed, 37 insertions(+), 41 deletions(-) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 35391e64c..92f1eb5a1 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -266,7 +266,7 @@ module SignatureVerification 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 b3d0d032c..cc05b7a40 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -155,8 +155,8 @@ module JsonLdHelper 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/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 51384ef98..322f3e27a 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -154,7 +154,7 @@ class ActivityPub::Activity 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 faea63e8f..9459fdd8b 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature 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/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 567dd8a14..7b083d889 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 8df8c7587..86a134bb4 100644 --- a/app/services/activitypub/fetch_remote_actor_service.rb +++ b/app/services/activitypub/fetch_remote_actor_service.rb @@ -10,15 +10,15 @@ class ActivityPub::FetchRemoteActorService < BaseService 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 8eb97c1e6..e96b5ad3b 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 e3a9b60b5..6f8882378 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 @@ class ActivityPub::FetchRemoteStatusService < BaseService 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 8fc0989a3..9e787ace5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -277,7 +277,7 @@ class ActivityPub::ProcessAccountService < BaseService 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 a3406e5a5..71c6cca79 100644 --- a/app/services/fetch_resource_service.rb +++ b/app/services/fetch_resource_service.rb @@ -48,7 +48,15 @@ class FetchResourceService < BaseService 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/lib/mastodon/version.rb b/lib/mastodon/version.rb index 587e89303..dd7c84207 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -17,7 +17,7 @@ module Mastodon end def default_prerelease - 'alpha.0' + 'alpha.1' end def prerelease diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 97268eea6..1af45673c 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -56,7 +56,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do 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 @@ RSpec.describe ActivityPub::LinkedDataSignature do 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 4015723f6..e7f6bb8dd 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 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do 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 485ca81a1..e622c7d4c 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 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do 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 e210d20ec..0b14da4f4 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 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do 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 0f1068471..78037a06c 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -57,7 +57,7 @@ RSpec.describe FetchResourceService, type: :service do let(:json) do { - id: 1, + id: 'http://example.com/foo', '@context': ActivityPub::TagManager::CONTEXT, type: 'Note', }.to_json @@ -83,27 +83,27 @@ RSpec.describe FetchResourceService, type: :service do 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 bcfb9dbfb..5270cc10d 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do 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