Merge branch 'feed-suggestions' into 'develop'
Feed suggestions See merge request soapbox-pub/soapbox-fe!1595
This commit is contained in:
commit
1254f8a3f3
28 changed files with 480 additions and 207 deletions
108
app/soapbox/actions/__tests__/suggestions.test.ts
Normal file
108
app/soapbox/actions/__tests__/suggestions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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'>·</Text>
|
||||
{(timestamp) ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</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'>·</Text>
|
||||
{showEdit ? (
|
||||
<>
|
||||
<Text tag='span' theme='muted' size='sm'>·</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>
|
||||
|
||||
|
|
|
@ -137,6 +137,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
|||
timestamp={status.created_at}
|
||||
withRelationship={false}
|
||||
showProfileHoverCard={!compose}
|
||||
withLinkToProfile={!compose}
|
||||
/>
|
||||
|
||||
{renderReplyMentions()}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
92
app/soapbox/features/feed-suggestions/feed-suggestions.tsx
Normal file
92
app/soapbox/features/feed-suggestions/feed-suggestions.tsx
Normal 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;
|
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 { 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;
|
|
@ -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;
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -49,6 +49,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
|
|||
<AccountContainer
|
||||
id={status.account as any}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
timestamp={status.created_at}
|
||||
hideActions
|
||||
/>
|
||||
|
|
|
@ -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 />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue