Build Feed Suggestions
This commit is contained in:
parent
1309521b9c
commit
da98a1e137
14 changed files with 304 additions and 164 deletions
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
85
app/soapbox/features/feed-suggestions/feed-suggestions.tsx
Normal file
85
app/soapbox/features/feed-suggestions/feed-suggestions.tsx
Normal 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;
|
82
app/soapbox/features/follow-recommendations/index.tsx
Normal file
82
app/soapbox/features/follow-recommendations/index.tsx
Normal 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;
|
|
@ -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;
|
|
|
@ -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's get started!' /></h2>
|
|
||||||
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage='Don'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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue