Merge branch 'feed-suggestions' into 'develop'
Feed suggestions See merge request soapbox-pub/soapbox-fe!1595
This commit is contained in:
commit
1254f8a3f3
|
@ -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 { 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') => {
|
||||||
|
@ -141,7 +142,7 @@ const replaceHomeTimeline = (
|
||||||
{ maxId }: Record<string, any> = {},
|
{ maxId }: Record<string, any> = {},
|
||||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||||
dispatch({ type: TIMELINE_REPLACE, accountId });
|
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) =>
|
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) => {
|
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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts';
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
import { displayFqn } from 'soapbox/utils/state';
|
||||||
|
|
||||||
import RelativeTimestamp from './relative_timestamp';
|
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';
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -57,7 +57,9 @@ interface IAccount {
|
||||||
timestamp?: string | Date,
|
timestamp?: string | Date,
|
||||||
timestampUrl?: string,
|
timestampUrl?: string,
|
||||||
futureTimestamp?: boolean,
|
futureTimestamp?: boolean,
|
||||||
|
withAccountNote?: boolean,
|
||||||
withDate?: boolean,
|
withDate?: boolean,
|
||||||
|
withLinkToProfile?: boolean,
|
||||||
withRelationship?: boolean,
|
withRelationship?: boolean,
|
||||||
showEdit?: boolean,
|
showEdit?: boolean,
|
||||||
emoji?: string,
|
emoji?: string,
|
||||||
|
@ -78,7 +80,9 @@ const Account = ({
|
||||||
timestamp,
|
timestamp,
|
||||||
timestampUrl,
|
timestampUrl,
|
||||||
futureTimestamp = false,
|
futureTimestamp = false,
|
||||||
|
withAccountNote = false,
|
||||||
withDate = false,
|
withDate = false,
|
||||||
|
withLinkToProfile = true,
|
||||||
withRelationship = true,
|
withRelationship = true,
|
||||||
showEdit = false,
|
showEdit = false,
|
||||||
emoji,
|
emoji,
|
||||||
|
@ -154,12 +158,12 @@ const Account = ({
|
||||||
|
|
||||||
if (withDate) timestamp = account.created_at;
|
if (withDate) timestamp = account.created_at;
|
||||||
|
|
||||||
const LinkEl: any = showProfileHoverCard ? Link : 'div';
|
const LinkEl: any = withLinkToProfile ? Link : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
<div data-testid='account' className='flex-shrink-0 group block w-full' ref={overflowRef}>
|
||||||
<HStack alignItems={actionAlignment} justifyContent='between'>
|
<HStack alignItems={actionAlignment} justifyContent='between'>
|
||||||
<HStack alignItems='center' space={3}>
|
<HStack alignItems={withAccountNote ? 'top' : 'center'} space={3}>
|
||||||
<ProfilePopper
|
<ProfilePopper
|
||||||
condition={showProfileHoverCard}
|
condition={showProfileHoverCard}
|
||||||
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
wrapper={(children) => <HoverRefWrapper className='relative' accountId={account.id} inline>{children}</HoverRefWrapper>}
|
||||||
|
@ -202,35 +206,45 @@ const Account = ({
|
||||||
</LinkEl>
|
</LinkEl>
|
||||||
</ProfilePopper>
|
</ProfilePopper>
|
||||||
|
|
||||||
<HStack alignItems='center' space={1} style={style}>
|
<Stack space={withAccountNote ? 1 : 0}>
|
||||||
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
<HStack alignItems='center' space={1} style={style}>
|
||||||
|
<Text theme='muted' size='sm' truncate>@{username}</Text>
|
||||||
|
|
||||||
{account.favicon && (
|
{account.favicon && (
|
||||||
<InstanceFavicon account={account} />
|
<InstanceFavicon account={account} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(timestamp) ? (
|
{(timestamp) ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||||
|
|
||||||
{timestampUrl ? (
|
{timestampUrl ? (
|
||||||
<Link to={timestampUrl} className='hover:underline'>
|
<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} />
|
<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 ? (
|
{showEdit ? (
|
||||||
<>
|
<>
|
||||||
<Text tag='span' theme='muted' size='sm'>·</Text>
|
<Text tag='span' theme='muted' size='sm'>·</Text>
|
||||||
|
|
||||||
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
|
<Icon className='h-5 w-5 stroke-[1.35]' src={require('@tabler/icons/pencil.svg')} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
{withAccountNote && (
|
||||||
|
<Text
|
||||||
|
size='sm'
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
||||||
|
className='mr-2'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
|
|
|
@ -137,6 +137,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
withRelationship={false}
|
withRelationship={false}
|
||||||
showProfileHoverCard={!compose}
|
showProfileHoverCard={!compose}
|
||||||
|
withLinkToProfile={!compose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderReplyMentions()}
|
{renderReplyMentions()}
|
||||||
|
|
|
@ -34,6 +34,12 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
|
||||||
const [scrolled, setScrolled] = useState<boolean>(false);
|
const [scrolled, setScrolled] = useState<boolean>(false);
|
||||||
const autoload = settings.get('autoloadTimelines') === true;
|
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 => {
|
const getScrollTop = (): number => {
|
||||||
return (document.scrollingElement || document.documentElement).scrollTop;
|
return (document.scrollingElement || document.documentElement).scrollTop;
|
||||||
};
|
};
|
||||||
|
@ -75,12 +81,6 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
|
||||||
maybeUnload();
|
maybeUnload();
|
||||||
}, [count]);
|
}, [count]);
|
||||||
|
|
||||||
const visible = count > 0 && scrolled;
|
|
||||||
|
|
||||||
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
|
|
||||||
'hidden': !visible,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<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}>
|
<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 getAccount = makeGetAccount();
|
||||||
const instance = useAppSelector((state) => state.instance);
|
const instance = useAppSelector((state) => state.instance);
|
||||||
const me = useAppSelector((state) => state.me);
|
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 otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
|
||||||
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
|
||||||
const settings = useAppSelector((state) => getSettings(state));
|
const settings = useAppSelector((state) => getSettings(state));
|
||||||
|
@ -121,7 +121,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
const renderAccount = (account: AccountEntity) => (
|
const renderAccount = (account: AccountEntity) => (
|
||||||
<a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
|
<a href='#' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
|
||||||
<div className='pointer-events-none'>
|
<div className='pointer-events-none'>
|
||||||
<Account account={account} showProfileHoverCard={false} withRelationship={false} />
|
<Account account={account} showProfileHoverCard={false} withRelationship={false} withLinkToProfile={false} />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -166,7 +166,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
|
|
||||||
<Stack space={1}>
|
<Stack space={1}>
|
||||||
<Link to={`/@${account.acct}`} onClick={onClose}>
|
<Link to={`/@${account.acct}`} onClick={onClose}>
|
||||||
<Account account={account} showProfileHoverCard={false} />
|
<Account account={account} showProfileHoverCard={false} withLinkToProfile={false} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Stack>
|
<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);
|
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) {
|
if (this.props.getScrollPosition) {
|
||||||
return this.props.getScrollPosition();
|
return this.props.getScrollPosition() || null;
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -481,6 +481,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
hideActions={!reblogElement}
|
hideActions={!reblogElement}
|
||||||
showEdit={!!status.edited_at}
|
showEdit={!!status.edited_at}
|
||||||
showProfileHoverCard={this.props.hoverable}
|
showProfileHoverCard={this.props.hoverable}
|
||||||
|
withLinkToProfile={this.props.hoverable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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';
|
||||||
|
|
||||||
|
@ -77,7 +78,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 +150,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 {
|
||||||
|
|
|
@ -6,6 +6,7 @@ const justifyContentOptions = {
|
||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
start: 'justify-start',
|
start: 'justify-start',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
|
around: 'justify-around',
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignItemsOptions = {
|
const alignItemsOptions = {
|
||||||
|
@ -32,7 +33,7 @@ interface IHStack {
|
||||||
/** Extra class names on the <div> element. */
|
/** Extra class names on the <div> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
/** Horizontal alignment of children. */
|
/** Horizontal alignment of children. */
|
||||||
justifyContent?: 'between' | 'center' | 'start' | 'end',
|
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
|
||||||
/** Size of the gap between elements. */
|
/** Size of the gap between elements. */
|
||||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
|
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
|
||||||
/** Whether to let the flexbox grow. */
|
/** Whether to let the flexbox grow. */
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
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 = {
|
const spaces = {
|
||||||
|
0: 'space-y-0',
|
||||||
'0.5': 'space-y-0.5',
|
'0.5': 'space-y-0.5',
|
||||||
1: 'space-y-1',
|
1: 'space-y-1',
|
||||||
'1.5': 'space-y-1.5',
|
'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}
|
id={status.getIn(['account', 'id']) as string}
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
showProfileHoverCard={false}
|
showProfileHoverCard={false}
|
||||||
|
withLinkToProfile={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
|
|
|
@ -41,7 +41,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
@ -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
|
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
|
||||||
id={suggestion.account}
|
id={suggestion.account}
|
||||||
showProfileHoverCard={false}
|
showProfileHoverCard={false}
|
||||||
|
withLinkToProfile={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -60,6 +60,7 @@ const ActionsModal: React.FC<IActionsModal> = ({ status, actions, onClick, onClo
|
||||||
key={status.account as string}
|
key={status.account as string}
|
||||||
id={status.account as string}
|
id={status.account as string}
|
||||||
showProfileHoverCard={false}
|
showProfileHoverCard={false}
|
||||||
|
withLinkToProfile={false}
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
/>
|
/>
|
||||||
<StatusContent status={status} />
|
<StatusContent status={status} />
|
||||||
|
|
|
@ -49,6 +49,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => {
|
||||||
<AccountContainer
|
<AccountContainer
|
||||||
id={status.account as any}
|
id={status.account as any}
|
||||||
showProfileHoverCard={false}
|
showProfileHoverCard={false}
|
||||||
|
withLinkToProfile={false}
|
||||||
timestamp={status.created_at}
|
timestamp={status.created_at}
|
||||||
hideActions
|
hideActions
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -58,7 +58,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
|
|
||||||
const renderAccount = (account: AccountEntity) => {
|
const renderAccount = (account: AccountEntity) => {
|
||||||
return (
|
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 { 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 New Issue