From 9cdc60ecc6e5746b706bdcf19d0743d1c153105f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 1 Feb 2024 14:37:04 +0100 Subject: [PATCH] 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 870a311024d..8eafe38b21a 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 08561c71f49..4ce7c3cf841 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 e92dd233e15..3ed20f65eb3 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 960d30e2cae..00000000000 --- 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 00000000000..ac414d04d6a --- /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 069f52b0bea..6e7dc2b6c8c 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 22a831b0932..12d0068d69e 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 a605ecbb8bd..0e353e0d1b7 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 0f224ff4b94..5b9d983dea4 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 43dedd6e6d8..4c9ab98a82e 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 00000000000..53783746ae6 --- /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 00000000000..53783746ae6 --- /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 00000000000..41004673651 --- /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 00000000000..41004673651 --- /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 3c75854d9b0..520e91e28bb 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 5b89e7f25d8..f70fa12a51a 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%; + } + } + } + } +}