Build Feed Suggestions

This commit is contained in:
Justin 2022-07-01 16:09:07 -04:00
parent 1309521b9c
commit da98a1e137
14 changed files with 304 additions and 164 deletions

View file

@ -1,3 +1,5 @@
import { AxiosResponse } from 'axios';
import { isLoggedIn } from 'soapbox/utils/auth'; import { isLoggedIn } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
@ -5,6 +7,7 @@ import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { insertSuggestionsIntoTimeline } from './timelines';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities'; 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_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; 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> = {}) => const fetchSuggestionsV1 = (params: Record<string, any> = {}) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); 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 }) => const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
const instance = state.instance; const instance = state.instance;
const features = getFeatures(instance); const features = getFeatures(instance);
if (!me) return; if (!me) return null;
if (features.suggestionsV2) { if (features.truthSuggestions) {
dispatch(fetchSuggestionsV2(params)) 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[]) => { .then((suggestions: APIEntity[]) => {
const accountIds = suggestions.map(({ account }) => account.id); const accountIds = suggestions.map(({ account }) => account.id);
dispatch(fetchRelationships(accountIds)); dispatch(fetchRelationships(accountIds));
}) })
.catch(() => { }); .catch(() => { });
} else if (features.suggestions) { } else if (features.suggestions) {
dispatch(fetchSuggestionsV1(params)) return dispatch(fetchSuggestionsV1(params))
.then((accounts: APIEntity[]) => { .then((accounts: APIEntity[]) => {
const accountIds = accounts.map(({ id }) => id); const accountIds = accounts.map(({ id }) => id);
dispatch(fetchRelationships(accountIds)); dispatch(fetchRelationships(accountIds));
@ -77,9 +133,14 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
.catch(() => { }); .catch(() => { });
} else { } else {
// Do nothing // Do nothing
return null;
} }
}; };
const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => {
dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline()));
};
const dismissSuggestion = (accountId: string) => const dismissSuggestion = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
@ -100,8 +161,12 @@ export {
SUGGESTIONS_V2_FETCH_REQUEST, SUGGESTIONS_V2_FETCH_REQUEST,
SUGGESTIONS_V2_FETCH_SUCCESS, SUGGESTIONS_V2_FETCH_SUCCESS,
SUGGESTIONS_V2_FETCH_FAIL, SUGGESTIONS_V2_FETCH_FAIL,
SUGGESTIONS_TRUTH_FETCH_REQUEST,
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
SUGGESTIONS_TRUTH_FETCH_FAIL,
fetchSuggestionsV1, fetchSuggestionsV1,
fetchSuggestionsV2, fetchSuggestionsV2,
fetchSuggestions, fetchSuggestions,
fetchSuggestionsForTimeline,
dismissSuggestion, dismissSuggestion,
}; };

View file

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

View file

@ -6,8 +6,13 @@ import { FormattedMessage } from 'react-intl';
import LoadGap from 'soapbox/components/load_gap'; import LoadGap from 'soapbox/components/load_gap';
import ScrollableList from 'soapbox/components/scrollable_list'; import ScrollableList from 'soapbox/components/scrollable_list';
import StatusContainer from 'soapbox/containers/status_container'; 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 PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status';
import { useAppSelector } from 'soapbox/hooks';
import { Button, Card, CardBody, CardTitle, HStack, Stack, Text } from './ui';
import VerificationBadge from './verification_badge';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso'; import type { VirtuosoHandle } from 'react-virtuoso';
@ -77,7 +82,7 @@ const StatusList: React.FC<IStatusList> = ({
const handleLoadOlder = useCallback(debounce(() => { const handleLoadOlder = useCallback(debounce(() => {
const maxId = lastStatusId || statusIds.last(); const maxId = lastStatusId || statusIds.last();
if (onLoadMore && maxId) { if (onLoadMore && maxId) {
onLoadMore(maxId); onLoadMore(maxId.replace('末suggestions-', ''));
} }
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]);
@ -149,11 +154,17 @@ const StatusList: React.FC<IStatusList> = ({
)); ));
}; };
const renderFeedSuggestions = (): React.ReactNode => {
return <FeedSuggestions key='suggestions' />;
};
const renderStatuses = (): React.ReactNode[] => { const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) { if (isLoading || statusIds.size > 0) {
return statusIds.toArray().map((statusId, index) => { return statusIds.toArray().map((statusId, index) => {
if (statusId === null) { if (statusId === null) {
return renderLoadGap(index); return renderLoadGap(index);
} else if (statusId.startsWith('末suggestions-')) {
return renderFeedSuggestions();
} else if (statusId.startsWith('末pending-')) { } else if (statusId.startsWith('末pending-')) {
return renderPendingStatus(statusId); return renderPendingStatus(statusId);
} else { } else {

View file

@ -0,0 +1,85 @@
import React from 'react';
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 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 suggestedProfiles = useAppSelector((state) => state.suggestions.items);
return (
<Card size='lg' variant='rounded'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title='Suggested profiles' />
<Link
to='/suggestions'
className='text-primary-600 dark:text-primary-400 hover:underline'
>
View all
</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 { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
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 FollowRecommendations: React.FC = () => {
const dispatch = useAppDispatch();
const features = useFeatures();
const history = useHistory();
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);
const onDone = () => {
history.push('/');
};
useEffect(() => {
dispatch(fetchSuggestions({ limit: 20 }));
}, []);
if (suggestions.size === 0 && !isLoading) {
return (
<Column label='Suggested profiles'>
<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='Suggested profiles'>
<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}
/>
))
) : (
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

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

View file

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

View file

@ -10,8 +10,11 @@ import {
SUGGESTIONS_V2_FETCH_REQUEST, SUGGESTIONS_V2_FETCH_REQUEST,
SUGGESTIONS_V2_FETCH_SUCCESS, SUGGESTIONS_V2_FETCH_SUCCESS,
SUGGESTIONS_V2_FETCH_FAIL, SUGGESTIONS_V2_FETCH_FAIL,
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
} from 'soapbox/actions/suggestions'; } from 'soapbox/actions/suggestions';
import { SuggestedProfile } from '../actions/suggestions';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities'; 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) => { const dismissAccount = (state: State, accountId: string) => {
return state.update('items', items => items.filterNot(item => item.account === accountId)); 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); return importAccounts(state, action.accounts);
case SUGGESTIONS_V2_FETCH_SUCCESS: case SUGGESTIONS_V2_FETCH_SUCCESS:
return importSuggestions(state, action.suggestions, action.next); 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_FETCH_FAIL:
case SUGGESTIONS_V2_FETCH_FAIL: case SUGGESTIONS_V2_FETCH_FAIL:
return state.set('isLoading', false); return state.set('isLoading', false);

View file

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

View file

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