Merge branch 'feed-suggestions' into 'develop'

Feed suggestions

See merge request soapbox-pub/soapbox-fe!1595
This commit is contained in:
Justin 2022-07-11 13:00:41 +00:00
commit 1254f8a3f3
28 changed files with 480 additions and 207 deletions

View file

@ -0,0 +1,108 @@
import { Map as ImmutableMap } from 'immutable';
import { __stub } from 'soapbox/api';
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { normalizeInstance } from 'soapbox/normalizers';
import {
fetchSuggestions,
} from '../suggestions';
let store: ReturnType<typeof mockStore>;
let state;
describe('fetchSuggestions()', () => {
describe('with Truth Social software', () => {
beforeEach(() => {
state = rootState
.set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
pleroma: ImmutableMap({
metadata: ImmutableMap({
features: [],
}),
}),
}))
.set('me', '123');
store = mockStore(state);
});
describe('with a successful API request', () => {
const response = [
{
account_id: '1',
acct: 'jl',
account_avatar: 'https://example.com/some.jpg',
display_name: 'justin',
note: '<p>note</p>',
verified: true,
},
];
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('dispatches the correct actions', async() => {
const expectedActions = [
{ type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true },
{
type: 'ACCOUNTS_IMPORT', accounts: [{
acct: response[0].acct,
avatar: response[0].account_avatar,
avatar_static: response[0].account_avatar,
id: response[0].account_id,
note: response[0].note,
verified: response[0].verified,
display_name: response[0].display_name,
}],
},
{
type: 'SUGGESTIONS_TRUTH_FETCH_SUCCESS',
suggestions: response,
next: undefined,
skipLoading: true,
},
{
type: 'RELATIONSHIPS_FETCH_REQUEST',
skipLoading: true,
ids: [response[0].account_id],
},
];
await store.dispatch(fetchSuggestions());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
describe('with an unsuccessful API request', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions').networkError();
});
});
it('should dispatch the correct actions', async() => {
const expectedActions = [
{ type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true },
{
type: 'SUGGESTIONS_V2_FETCH_FAIL',
error: new Error('Network Error'),
skipLoading: true,
skipAlert: true,
},
];
await store.dispatch(fetchSuggestions());
const actions = store.getActions();
expect(actions).toEqual(expectedActions);
});
});
});
});

View file

@ -1,3 +1,5 @@
import { AxiosResponse } from 'axios';
import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features';
@ -5,6 +7,7 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
import { insertSuggestionsIntoTimeline } from './timelines';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities';
@ -19,6 +22,10 @@ const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST';
const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
const SUGGESTIONS_TRUTH_FETCH_REQUEST = 'SUGGESTIONS_TRUTH_FETCH_REQUEST';
const SUGGESTIONS_TRUTH_FETCH_SUCCESS = 'SUGGESTIONS_TRUTH_FETCH_SUCCESS';
const SUGGESTIONS_TRUTH_FETCH_FAIL = 'SUGGESTIONS_TRUTH_FETCH_FAIL';
const fetchSuggestionsV1 = (params: Record<string, any> = {}) =>
(dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true });
@ -52,6 +59,48 @@ const fetchSuggestionsV2 = (params: Record<string, any> = {}) =>
});
};
export type SuggestedProfile = {
account_avatar: string
account_id: string
acct: string
display_name: string
note: string
verified: boolean
}
const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({
id: suggestedProfile.account_id,
avatar: suggestedProfile.account_avatar,
avatar_static: suggestedProfile.account_avatar,
acct: suggestedProfile.acct,
display_name: suggestedProfile.display_name,
note: suggestedProfile.note,
verified: suggestedProfile.verified,
});
const fetchTruthSuggestions = (params: Record<string, any> = {}) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const next = getState().suggestions.next;
dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true });
return api(getState)
.get(next ? next : '/api/v1/truth/carousels/suggestions', next ? {} : { params })
.then((response: AxiosResponse<SuggestedProfile[]>) => {
const suggestedProfiles = response.data;
const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
const accounts = suggestedProfiles.map(mapSuggestedProfileToAccount);
dispatch(importFetchedAccounts(accounts));
dispatch({ type: SUGGESTIONS_TRUTH_FETCH_SUCCESS, suggestions: suggestedProfiles, next, skipLoading: true });
return suggestedProfiles;
})
.catch(error => {
dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true });
throw error;
});
};
const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
const instance = state.instance;
const features = getFeatures(instance);
if (!me) return;
if (!me) return null;
if (features.suggestionsV2) {
dispatch(fetchSuggestionsV2(params))
if (features.truthSuggestions) {
return dispatch(fetchTruthSuggestions(params))
.then((suggestions: APIEntity[]) => {
const accountIds = suggestions.map((account) => account.account_id);
dispatch(fetchRelationships(accountIds));
})
.catch(() => { });
} else if (features.suggestionsV2) {
return dispatch(fetchSuggestionsV2(params))
.then((suggestions: APIEntity[]) => {
const accountIds = suggestions.map(({ account }) => account.id);
dispatch(fetchRelationships(accountIds));
})
.catch(() => { });
} else if (features.suggestions) {
dispatch(fetchSuggestionsV1(params))
return dispatch(fetchSuggestionsV1(params))
.then((accounts: APIEntity[]) => {
const accountIds = accounts.map(({ id }) => id);
dispatch(fetchRelationships(accountIds));
@ -77,9 +133,14 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
.catch(() => { });
} else {
// Do nothing
return null;
}
};
const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => {
dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline()));
};
const dismissSuggestion = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
@ -100,8 +161,12 @@ export {
SUGGESTIONS_V2_FETCH_REQUEST,
SUGGESTIONS_V2_FETCH_SUCCESS,
SUGGESTIONS_V2_FETCH_FAIL,
SUGGESTIONS_TRUTH_FETCH_REQUEST,
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
SUGGESTIONS_TRUTH_FETCH_FAIL,
fetchSuggestionsV1,
fetchSuggestionsV2,
fetchSuggestions,
fetchSuggestionsForTimeline,
dismissSuggestion,
};

View file

@ -12,21 +12,22 @@ import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Status } from 'soapbox/types/entities';
const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
const TIMELINE_DELETE = 'TIMELINE_DELETE';
const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
const TIMELINE_DELETE = 'TIMELINE_DELETE';
const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE';
const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE';
const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
const TIMELINE_REPLACE = 'TIMELINE_REPLACE';
const TIMELINE_INSERT = 'TIMELINE_INSERT';
const MAX_QUEUED_ITEMS = 40;
@ -110,9 +111,9 @@ const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string)
const deleteFromTimelines = (id: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const accountId = getState().statuses.get(id)?.account;
const accountId = getState().statuses.get(id)?.account;
const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
const reblogOf = getState().statuses.getIn([id, 'reblog'], null);
const reblogOf = getState().statuses.getIn([id, 'reblog'], null);
dispatch({
type: TIMELINE_DELETE,
@ -127,7 +128,7 @@ const clearTimeline = (timeline: string) =>
(dispatch: AppDispatch) =>
dispatch({ type: TIMELINE_CLEAR, timeline });
const noOp = () => {};
const noOp = () => { };
const noOpAsync = () => () => new Promise(f => f(undefined));
const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none') => {
@ -141,7 +142,7 @@ const replaceHomeTimeline = (
{ maxId }: Record<string, any> = {},
) => (dispatch: AppDispatch, _getState: () => RootState) => {
dispatch({ type: TIMELINE_REPLACE, accountId });
dispatch(expandHomeTimeline({ accountId, maxId }));
dispatch(expandHomeTimeline({ accountId, maxId }, () => dispatch(insertSuggestionsIntoTimeline())));
};
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
@ -214,9 +215,9 @@ const expandGroupTimeline = (id: string, { maxId }: Record<string, any> = {}, do
const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record<string, any> = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
}, done);
};
@ -259,6 +260,10 @@ const scrollTopTimeline = (timeline: string, top: boolean) => ({
top,
});
const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: TIMELINE_INSERT, timeline: 'home' });
};
export {
TIMELINE_UPDATE,
TIMELINE_DELETE,
@ -272,6 +277,7 @@ export {
TIMELINE_CONNECT,
TIMELINE_DISCONNECT,
TIMELINE_REPLACE,
TIMELINE_INSERT,
MAX_QUEUED_ITEMS,
processTimelineUpdate,
updateTimeline,
@ -298,4 +304,5 @@ export {
connectTimeline,
disconnectTimeline,
scrollTopTimeline,
insertSuggestionsIntoTimeline,
};

View file

@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn } from 'soapbox/utils/state';
import RelativeTimestamp from './relative_timestamp';
import { Avatar, Emoji, HStack, Icon, IconButton, Text } from './ui';
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { Account as AccountEntity } from 'soapbox/types/entities';
@ -57,7 +57,9 @@ interface IAccount {
timestamp?: string | Date,
timestampUrl?: string,
futureTimestamp?: boolean,
withAccountNote?: boolean,
withDate?: boolean,
withLinkToProfile?: boolean,
withRelationship?: boolean,
showEdit?: boolean,
emoji?: string,
@ -78,7 +80,9 @@ const Account = ({
timestamp,
timestampUrl,
futureTimestamp = false,
withAccountNote = false,
withDate = false,
withLinkToProfile = true,
withRelationship = true,
showEdit = false,
emoji,
@ -154,12 +158,12 @@ const Account = ({
if (withDate) timestamp = account.created_at;
const LinkEl: any = showProfileHoverCard ? Link : 'div';
const LinkEl: any = withLinkToProfile ? Link : 'div';
return (
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
<HStack alignItems={actionAlignment} justifyContent='between'>
<HStack alignItems='center' space={3}>
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
<ProfilePopper
condition={showProfileHoverCard}
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
@ -202,35 +206,45 @@ const Account = ({
</LinkEl>
</ProfilePopper>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
<Stack space={withAccountNote ? 1 : 0}>
<HStack alignItems='center' space={1} style={style}>
<Text theme='muted' size='sm' truncate>@{username}</Text>
{account.favicon && (
<InstanceFavicon account={account} />
)}
{account.favicon && (
<InstanceFavicon account={account} />
)}
{(timestamp) ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{(timestamp) ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'>
{timestampUrl ? (
<Link to={timestampUrl} className='hover:underline'>
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link>
) : (
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
</Link>
) : (
<RelativeTimestamp timestamp={timestamp} theme='muted' size='sm' className='whitespace-nowrap' futureDate={futureTimestamp} />
)}
</>
) : null}
)}
</>
) : null}
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
{showEdit ? (
<>
<Text tag='span' theme='muted' size='sm'>&middot;</Text>
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
</>
) : null}
</HStack>
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
</>
) : null}
</HStack>
{withAccountNote && (
<Text
size='sm'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
className='mr-2'
/>
)}
</Stack>
</div>
</HStack>

View file

@ -137,6 +137,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
timestamp={status.created_at}
withRelationship={false}
showProfileHoverCard={!compose}
withLinkToProfile={!compose}
/>
{renderReplyMentions()}

View file

@ -34,6 +34,12 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
const [scrolled, setScrolled] = useState<boolean>(false);
const autoload = settings.get('autoloadTimelines') === true;
const visible = count > 0 && scrolled;
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
const getScrollTop = (): number => {
return (document.scrollingElement || document.documentElement).scrollTop;
};
@ -75,12 +81,6 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
maybeUnload();
}, [count]);
const visible = count > 0 && scrolled;
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
return (
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={handleClick}>

View file

@ -84,7 +84,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const getAccount = makeGetAccount();
const instance = useAppSelector((state) => state.instance);
const me = useAppSelector((state) => state.me);
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const settings = useAppSelector((state) => getSettings(state));
@ -121,7 +121,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
const renderAccount = (account: AccountEntity) => (
<a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
<div className='pointer-events-none'>
<Account account={account} showProfileHoverCard={false} withRelationship={false} />
<Account account={account} showProfileHoverCard={false} withRelationship={false} withLinkToProfile={false} />
</div>
</a>
);
@ -166,7 +166,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
<Stack space={1}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} />
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
</Link>
<Stack>

View file

@ -132,11 +132,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card);
}
getSnapshotBeforeUpdate(): ScrollPosition | undefined {
getSnapshotBeforeUpdate(): ScrollPosition | null {
if (this.props.getScrollPosition) {
return this.props.getScrollPosition();
return this.props.getScrollPosition() || null;
} else {
return undefined;
return null;
}
}
@ -481,6 +481,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
hideActions={!reblogElement}
showEdit={!!status.edited_at}
showProfileHoverCard={this.props.hoverable}
withLinkToProfile={this.props.hoverable}
/>
</div>

View file

@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl';
import LoadGap from 'soapbox/components/load_gap';
import ScrollableList from 'soapbox/components/scrollable_list';
import StatusContainer from 'soapbox/containers/status_container';
import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status';
@ -77,7 +78,7 @@ const StatusList: React.FC<IStatusList> = ({
const handleLoadOlder = useCallback(debounce(() => {
const maxId = lastStatusId || statusIds.last();
if (onLoadMore && maxId) {
onLoadMore(maxId);
onLoadMore(maxId.replace('末suggestions-', ''));
}
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]);
@ -149,11 +150,17 @@ const StatusList: React.FC<IStatusList> = ({
));
};
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
};
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) {
return statusIds.toArray().map((statusId, index) => {
if (statusId === null) {
return renderLoadGap(index);
} else if (statusId.startsWith('末suggestions-')) {
return renderFeedSuggestions();
} else if (statusId.startsWith('末pending-')) {
return renderPendingStatus(statusId);
} else {

View file

@ -6,6 +6,7 @@ const justifyContentOptions = {
center: 'justify-center',
start: 'justify-start',
end: 'justify-end',
around: 'justify-around',
};
const alignItemsOptions = {
@ -32,7 +33,7 @@ interface IHStack {
/** Extra class names on the <div> element. */
className?: string,
/** Horizontal alignment of children. */
justifyContent?: 'between' | 'center' | 'start' | 'end',
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
/** Size of the gap between elements. */
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
/** Whether to let the flexbox grow. */

View file

@ -1,9 +1,10 @@
import classNames from 'classnames';
import React from 'react';
type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10
type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10
const spaces = {
0: 'space-y-0',
'0.5': 'space-y-0.5',
1: 'space-y-1',
'1.5': 'space-y-1.5',

View file

@ -39,6 +39,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ status, hideActions, onCanc
id={status.getIn(['account', 'id']) as string}
timestamp={status.created_at}
showProfileHoverCard={false}
withLinkToProfile={false}
/>
<Text

View file

@ -41,7 +41,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
/>
</div>
<Text theme='muted' size='sm' truncate align='center' className='leading-3'>{avatar.acct}</Text>
<Text theme='muted' size='sm' truncate align='center' className='leading-3 pb-0.5'>{avatar.acct}</Text>
</Stack>
</div>
);

View file

@ -0,0 +1,92 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import VerificationBadge from 'soapbox/components/verification_badge';
import { useAccount, useAppSelector } from 'soapbox/hooks';
import { Card, CardBody, CardTitle, HStack, Stack, Text } from '../../components/ui';
import ActionButton from '../ui/components/action-button';
import type { Account } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'feedSuggestions.heading', defaultMessage: 'Suggested profiles' },
viewAll: { id: 'feedSuggestions.viewAll', defaultMessage: 'View all' },
});
const SuggestionItem = ({ accountId }: { accountId: string }) => {
const account = useAccount(accountId) as Account;
return (
<Stack className='w-24 h-auto' space={3}>
<Link
to={`/@${account.acct}`}
title={account.acct}
>
<Stack space={3}>
<img
src={account.avatar}
className='mx-auto block w-16 h-16 min-w-[56px] rounded-full'
alt={account.acct}
/>
<Stack>
<HStack alignItems='center' justifyContent='center' space={1}>
<Text
weight='semibold'
dangerouslySetInnerHTML={{ __html: account.display_name }}
truncate
align='center'
size='sm'
className='max-w-[95%]'
/>
{account.verified && <VerificationBadge />}
</HStack>
<Text theme='muted' align='center' size='sm' truncate>@{account.acct}</Text>
</Stack>
</Stack>
</Link>
<div className='text-center'>
<ActionButton account={account} />
</div>
</Stack>
);
};
const FeedSuggestions = () => {
const intl = useIntl();
const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
return (
<Card size='lg' variant='rounded'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />
<Link
to='/suggestions'
className='text-primary-600 dark:text-primary-400 hover:underline'
>
{intl.formatMessage(messages.viewAll)}
</Link>
</HStack>
<CardBody>
<HStack
alignItems='center'
justifyContent='around'
space={8}
>
{suggestedProfiles.slice(0, 4).map((suggestedProfile) => (
<SuggestionItem key={suggestedProfile.account} accountId={suggestedProfile.account} />
))}
</HStack>
</CardBody>
</Card>
);
};
export default FeedSuggestions;

View file

@ -0,0 +1,82 @@
import debounce from 'lodash/debounce';
import React, { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchSuggestions } from 'soapbox/actions/suggestions';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
});
const FollowRecommendations: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const suggestions = useAppSelector((state) => state.suggestions.items);
const hasMore = useAppSelector((state) => !!state.suggestions.next);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const handleLoadMore = debounce(() => {
if (isLoading) {
return null;
}
return dispatch(fetchSuggestions({ limit: 20 }));
}, 300);
useEffect(() => {
dispatch(fetchSuggestions({ limit: 20 }));
}, []);
if (suggestions.size === 0 && !isLoading) {
return (
<Column label={intl.formatMessage(messages.heading)}>
<Text align='center'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</Text>
</Column>
);
}
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<ScrollableList
isLoading={isLoading}
scrollKey='suggestions'
onLoadMore={handleLoadMore}
hasMore={hasMore}
itemClassName='pb-4'
>
{features.truthSuggestions ? (
suggestions.map((suggestedProfile) => (
<AccountContainer
key={suggestedProfile.account}
id={suggestedProfile.account}
withAccountNote
showProfileHoverCard={false}
actionAlignment='top'
/>
))
) : (
suggestions.map((suggestion) => (
<AccountContainer
key={suggestion.account}
id={suggestion.account}
withAccountNote
/>
))
)}
</ScrollableList>
</Stack>
</Column>
);
};
export default FollowRecommendations;

View file

@ -1,46 +0,0 @@
import React from 'react';
import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name';
import Permalink from 'soapbox/components/permalink';
import ActionButton from 'soapbox/features/ui/components/action-button';
import { useAppSelector } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors';
const getAccount = makeGetAccount();
const getFirstSentence = (str: string) => {
const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
return arr[0];
};
interface IAccount {
id: string,
}
const Account: React.FC<IAccount> = ({ id }) => {
const account = useAppSelector((state) => getAccount(state, id));
if (!account) return null;
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.acct} href={account.url} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
<ActionButton account={account} />
</div>
</div>
</div>
);
};
export default Account;

View file

@ -1,30 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from 'soapbox/components/ui';
import FollowRecommendationsList from './follow_recommendations_list';
interface IFollowRecommendationsContainer {
onDone: () => void,
}
const FollowRecommendationsContainer: React.FC<IFollowRecommendationsContainer> = ({ onDone }) => (
<div className='scrollable follow-recommendations-container'>
<div className='column-title'>
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<h2 className='follow_subhead'><FormattedMessage id='follow_recommendation.subhead' defaultMessage='Let&#39;s get started!' /></h2>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage='Don&#39;t be afraid to make mistakes; you can unfollow people at any time.' /></p>
</div>
<FollowRecommendationsList />
<div className='column-actions'>
<Button onClick={onDone}>
<FormattedMessage id='follow_recommendations.done' defaultMessage='Done' />
</Button>
</div>
</div>
);
export default FollowRecommendationsContainer;

View file

@ -1,44 +0,0 @@
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchSuggestions } from 'soapbox/actions/suggestions';
import { Spinner } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import Account from './account';
const FollowRecommendationsList: React.FC = () => {
const dispatch = useDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
useEffect(() => {
if (suggestions.size === 0) {
dispatch(fetchSuggestions());
}
}, []);
if (isLoading) {
return (
<div className='column-list'>
<Spinner />
</div>
);
}
return (
<div className='column-list'>
{suggestions.size > 0 ? suggestions.map((suggestion) => (
<Account key={suggestion.account} id={suggestion.account} />
)) : (
<div className='column-list__empty-message'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</div>
)}
</div>
);
};
export default FollowRecommendationsList;

View file

@ -1,22 +0,0 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import Column from 'soapbox/features/ui/components/column';
import FollowRecommendationsContainer from './components/follow_recommendations_container';
const FollowRecommendations: React.FC = () => {
const history = useHistory();
const onDone = () => {
history.push('/');
};
return (
<Column>
<FollowRecommendationsContainer onDone={onDone} />
</Column>
);
};
export default FollowRecommendations;

View file

@ -45,6 +45,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
showProfileHoverCard={false}
withLinkToProfile={false}
/>
</div>
))}

View file

@ -60,6 +60,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
key={status.account as string}
id={status.account as string}
showProfileHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
/>
<StatusContent status={status} />

View file

@ -49,6 +49,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
<AccountContainer
id={status.account as any}
showProfileHoverCard={false}
withLinkToProfile={false}
timestamp={status.created_at}
hideActions
/>

View file

@ -58,7 +58,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
const renderAccount = (account: AccountEntity) => {
return (
<Account account={account} showProfileHoverCard={false} hideActions />
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} hideActions />
);
};

View file

@ -19,6 +19,7 @@ import { expandNotifications } from 'soapbox/actions/notifications';
import { register as registerPushNotifications } from 'soapbox/actions/push_notifications';
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses';
import { connectUserStream } from 'soapbox/actions/streaming';
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
@ -441,7 +442,9 @@ const UI: React.FC = ({ children }) => {
const loadAccountData = () => {
if (!account) return;
dispatch(expandHomeTimeline());
dispatch(expandHomeTimeline({}, () => {
dispatch(fetchSuggestionsForTimeline());
}));
dispatch(expandNotifications())
// @ts-ignore

View file

@ -455,7 +455,7 @@ export function WhoToFollowPanel() {
}
export function FollowRecommendations() {
return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
return import(/* webpackChunkName: "features/follow-recommendations" */'../../follow-recommendations');
}
export function Directory() {

View file

@ -10,8 +10,11 @@ import {
SUGGESTIONS_V2_FETCH_REQUEST,
SUGGESTIONS_V2_FETCH_SUCCESS,
SUGGESTIONS_V2_FETCH_FAIL,
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
} from 'soapbox/actions/suggestions';
import { SuggestedProfile } from '../actions/suggestions';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
@ -53,6 +56,14 @@ const importSuggestions = (state: State, suggestions: APIEntities, next: string
});
};
const importTruthSuggestions = (state: State, suggestions: SuggestedProfile[], next: string | null) => {
return state.withMutations(state => {
state.update('items', items => items.concat(suggestions.map(x => ({ ...x, account: x.account_id })).map(suggestion => SuggestionRecord(suggestion))));
state.set('isLoading', false);
state.set('next', next);
});
};
const dismissAccount = (state: State, accountId: string) => {
return state.update('items', items => items.filterNot(item => item.account === accountId));
};
@ -70,6 +81,8 @@ export default function suggestionsReducer(state: State = ReducerRecord(), actio
return importAccounts(state, action.accounts);
case SUGGESTIONS_V2_FETCH_SUCCESS:
return importSuggestions(state, action.suggestions, action.next);
case SUGGESTIONS_TRUTH_FETCH_SUCCESS:
return importTruthSuggestions(state, action.suggestions, action.next);
case SUGGESTIONS_FETCH_FAIL:
case SUGGESTIONS_V2_FETCH_FAIL:
return state.set('isLoading', false);

View file

@ -5,6 +5,7 @@ import {
Record as ImmutableRecord,
fromJS,
} from 'immutable';
import sample from 'lodash/sample';
import {
ACCOUNT_BLOCK_SUCCESS,
@ -30,6 +31,7 @@ import {
MAX_QUEUED_ITEMS,
TIMELINE_SCROLL_TOP,
TIMELINE_REPLACE,
TIMELINE_INSERT,
} from '../actions/timelines';
import type { AnyAction } from 'redux';
@ -37,7 +39,7 @@ import type { StatusVisibility } from 'soapbox/normalizers/status';
import type { APIEntity, Status } from 'soapbox/types/entities';
const TRUNCATE_LIMIT = 40;
const TRUNCATE_SIZE = 20;
const TRUNCATE_SIZE = 20;
const TimelineRecord = ImmutableRecord({
unread: 0,
@ -115,7 +117,7 @@ const expandNormalizedTimeline = (state: State, timelineId: string, statuses: Im
};
const updateTimeline = (state: State, timelineId: string, statusId: string) => {
const top = state.get(timelineId)?.top;
const top = state.get(timelineId)?.top;
const oldIds = state.get(timelineId)?.items || ImmutableOrderedSet<string>();
const unread = state.get(timelineId)?.unread || 0;
@ -135,8 +137,8 @@ const updateTimeline = (state: State, timelineId: string, statusId: string) => {
};
const updateTimelineQueue = (state: State, timelineId: string, statusId: string) => {
const queuedIds = state.get(timelineId)?.queuedItems || ImmutableOrderedSet<string>();
const listedIds = state.get(timelineId)?.items || ImmutableOrderedSet<string>();
const queuedIds = state.get(timelineId)?.queuedItems || ImmutableOrderedSet<string>();
const listedIds = state.get(timelineId)?.items || ImmutableOrderedSet<string>();
const queuedCount = state.get(timelineId)?.totalQueuedItemsCount || 0;
if (queuedIds.includes(statusId)) return state;
@ -353,6 +355,15 @@ export default function timelines(state: State = initialState, action: AnyAction
timeline.set('items', ImmutableOrderedSet([]));
}))
.update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', action.accountId));
case TIMELINE_INSERT:
return state.update(action.timeline, TimelineRecord(), timeline => timeline.withMutations(timeline => {
timeline.update('items', oldIds => {
const oldIdsArray = oldIds.toArray();
const positionInTimeline = sample([5, 6, 7, 8, 9]) as number;
oldIdsArray.splice(positionInTimeline, 0, `末suggestions-${oldIds.last()}`);
return ImmutableOrderedSet(oldIdsArray);
});
}));
default:
return state;
}

View file

@ -577,6 +577,11 @@ const getInstanceFeatures = (instance: Instance) => {
v.software === TRUTHSOCIAL,
]),
/**
* Supports Truth suggestions.
*/
truthSuggestions: v.software === TRUTHSOCIAL,
/**
* Whether the backend allows adding users you don't follow to lists.
* @see POST /api/v1/lists/:id/accounts