Skip to content

Commit

Permalink
Change onboarding prompt to follow suggestions carousel in web UI (ma…
Browse files Browse the repository at this point in the history
  • Loading branch information
Gargron committed Feb 1, 2024
1 parent 7316a08 commit 9cdc60e
Show file tree
Hide file tree
Showing 17 changed files with 507 additions and 138 deletions.
9 changes: 1 addition & 8 deletions app/javascript/mastodon/actions/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
};
21 changes: 21 additions & 0 deletions app/javascript/mastodon/actions/timelines.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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,
});
53 changes: 34 additions & 19 deletions app/javascript/mastodon/components/status_list.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -91,25 +93,38 @@ export default class StatusList extends ImmutablePureComponent {
}

let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId, index) => statusId === null ? (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
) : (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
showThread
withCounters={this.props.withCounters}
/>
))
statusIds.map((statusId, index) => {
switch(statusId) {
case TIMELINE_SUGGESTIONS:
return (
<InlineFollowSuggestions
key='inline-follow-suggestions'
/>
);
case TIMELINE_GAP:
return (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
);
default:
return (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
scrollKey={this.props.scrollKey}
showThread
withCounters={this.props.withCounters}
/>
);
}
})
) : null;

if (scrollableContent && featuredStatusIds) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'featured':
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage="Editors' Choice" />;
break;
case 'most_followed':
case 'most_interactions':
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
}

return (
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source'>
<Icon icon={InfoIcon} />
{label}
</div>
);
};

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 (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />

<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
</div>

<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={source.get(0)} />}
</div>

<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />
</div>
);
};

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 (
<div className='inline-follow-suggestions' />
);
}

return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>

<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
</div>
</div>

<div className='inline-follow-suggestions__body'>
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
{suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
source={suggestion.get('source')}
/>
))}
</div>

{canScrollLeft && (
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
</button>
)}

{canScrollRight && (
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
</button>
)}
</div>
</div>
);
};

InlineFollowSuggestions.propTypes = {
hidden: PropTypes.bool,
};
Loading

0 comments on commit 9cdc60e

Please sign in to comment.