Remove Truth Social feed carousel
This commit is contained in:
parent
8a3a908bef
commit
f01d088d06
|
@ -1,109 +0,0 @@
|
||||||
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,
|
|
||||||
should_refetch: true,
|
|
||||||
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,5 +1,3 @@
|
||||||
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';
|
||||||
|
|
||||||
|
@ -22,10 +20,6 @@ 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 });
|
||||||
|
@ -59,48 +53,6 @@ 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, { should_refetch: true }));
|
|
||||||
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();
|
||||||
|
@ -110,14 +62,7 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
|
||||||
|
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
|
|
||||||
if (features.truthSuggestions) {
|
if (features.suggestionsV2) {
|
||||||
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))
|
return dispatch(fetchSuggestionsV2(params))
|
||||||
.then((suggestions: APIEntity[]) => {
|
.then((suggestions: APIEntity[]) => {
|
||||||
const accountIds = suggestions.map(({ account }) => account.id);
|
const accountIds = suggestions.map(({ account }) => account.id);
|
||||||
|
@ -161,9 +106,6 @@ 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,
|
||||||
|
|
|
@ -27,9 +27,7 @@ const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const;
|
||||||
const TIMELINE_CONNECT = 'TIMELINE_CONNECT' as const;
|
const TIMELINE_CONNECT = 'TIMELINE_CONNECT' as const;
|
||||||
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT' as const;
|
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT' as const;
|
||||||
|
|
||||||
const TIMELINE_REPLACE = 'TIMELINE_REPLACE' as const;
|
|
||||||
const TIMELINE_INSERT = 'TIMELINE_INSERT' as const;
|
const TIMELINE_INSERT = 'TIMELINE_INSERT' as const;
|
||||||
const TIMELINE_CLEAR_FEED_ACCOUNT_ID = 'TIMELINE_CLEAR_FEED_ACCOUNT_ID' as const;
|
|
||||||
|
|
||||||
const MAX_QUEUED_ITEMS = 40;
|
const MAX_QUEUED_ITEMS = 40;
|
||||||
|
|
||||||
|
@ -149,20 +147,6 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const replaceHomeTimeline = (
|
|
||||||
accountId: string | undefined,
|
|
||||||
{ maxId }: Record<string, any> = {},
|
|
||||||
done?: () => void,
|
|
||||||
) => (dispatch: AppDispatch, _getState: () => RootState) => {
|
|
||||||
dispatch({ type: TIMELINE_REPLACE, accountId });
|
|
||||||
dispatch(expandHomeTimeline({ accountId, maxId }, () => {
|
|
||||||
dispatch(insertSuggestionsIntoTimeline());
|
|
||||||
if (done) {
|
|
||||||
done();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
|
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const timeline = getState().timelines.get(timelineId) || {} as Record<string, any>;
|
const timeline = getState().timelines.get(timelineId) || {} as Record<string, any>;
|
||||||
|
@ -209,7 +193,6 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ExpandHomeTimelineOpts {
|
interface ExpandHomeTimelineOpts {
|
||||||
accountId?: string
|
|
||||||
maxId?: string
|
maxId?: string
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
@ -220,19 +203,14 @@ interface HomeTimelineParams {
|
||||||
with_muted?: boolean
|
with_muted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
const expandHomeTimeline = ({ url, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
||||||
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
|
const endpoint = url || '/api/v1/timelines/home';
|
||||||
const params: HomeTimelineParams = {};
|
const params: HomeTimelineParams = {};
|
||||||
|
|
||||||
if (!url && maxId) {
|
if (!url && maxId) {
|
||||||
params.max_id = maxId;
|
params.max_id = maxId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountId) {
|
|
||||||
params.exclude_replies = true;
|
|
||||||
params.with_muted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandTimeline('home', endpoint, params, done);
|
return expandTimeline('home', endpoint, params, done);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -333,10 +311,6 @@ const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: ()
|
||||||
dispatch({ type: TIMELINE_INSERT, timeline: 'home' });
|
dispatch({ type: TIMELINE_INSERT, timeline: 'home' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFeedAccountId = () => (dispatch: AppDispatch, _getState: () => RootState) => {
|
|
||||||
dispatch({ type: TIMELINE_CLEAR_FEED_ACCOUNT_ID });
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: other actions
|
// TODO: other actions
|
||||||
type TimelineAction = TimelineDeleteAction;
|
type TimelineAction = TimelineDeleteAction;
|
||||||
|
|
||||||
|
@ -352,8 +326,6 @@ export {
|
||||||
TIMELINE_EXPAND_FAIL,
|
TIMELINE_EXPAND_FAIL,
|
||||||
TIMELINE_CONNECT,
|
TIMELINE_CONNECT,
|
||||||
TIMELINE_DISCONNECT,
|
TIMELINE_DISCONNECT,
|
||||||
TIMELINE_REPLACE,
|
|
||||||
TIMELINE_CLEAR_FEED_ACCOUNT_ID,
|
|
||||||
TIMELINE_INSERT,
|
TIMELINE_INSERT,
|
||||||
MAX_QUEUED_ITEMS,
|
MAX_QUEUED_ITEMS,
|
||||||
processTimelineUpdate,
|
processTimelineUpdate,
|
||||||
|
@ -363,7 +335,6 @@ export {
|
||||||
deleteFromTimelines,
|
deleteFromTimelines,
|
||||||
clearTimeline,
|
clearTimeline,
|
||||||
expandTimeline,
|
expandTimeline,
|
||||||
replaceHomeTimeline,
|
|
||||||
expandHomeTimeline,
|
expandHomeTimeline,
|
||||||
expandPublicTimeline,
|
expandPublicTimeline,
|
||||||
expandRemoteTimeline,
|
expandRemoteTimeline,
|
||||||
|
@ -385,6 +356,5 @@ export {
|
||||||
disconnectTimeline,
|
disconnectTimeline,
|
||||||
scrollTopTimeline,
|
scrollTopTimeline,
|
||||||
insertSuggestionsIntoTimeline,
|
insertSuggestionsIntoTimeline,
|
||||||
clearFeedAccountId,
|
|
||||||
type TimelineAction,
|
type TimelineAction,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,165 +0,0 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
|
|
||||||
import { render, screen, waitFor } from '../../../jest/test-helpers';
|
|
||||||
import FeedCarousel from '../feed-carousel';
|
|
||||||
|
|
||||||
vi.mock('../../../hooks/useDimensions', () => ({
|
|
||||||
useDimensions: () => [{ scrollWidth: 190 }, null, { width: 300 }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
(window as any).ResizeObserver = class ResizeObserver {
|
|
||||||
|
|
||||||
observe() { }
|
|
||||||
disconnect() { }
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('<FeedCarousel />', () => {
|
|
||||||
let store: any;
|
|
||||||
|
|
||||||
describe('with "carousel" enabled', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
store = {
|
|
||||||
instance: {
|
|
||||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
|
||||||
pleroma: ImmutableMap({
|
|
||||||
metadata: ImmutableMap({
|
|
||||||
features: [],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with avatars', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onGet('/api/v1/truth/carousels/avatars')
|
|
||||||
.reply(200, [
|
|
||||||
{ account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg', seen: false },
|
|
||||||
{ account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg', seen: false },
|
|
||||||
{ account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg', seen: false },
|
|
||||||
{ account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg', seen: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
mock.onGet('/api/v1/accounts/1/statuses').reply(200, [], {
|
|
||||||
link: '<https://example.com/api/v1/accounts/1/statuses?since_id=1>; rel=\'prev\'',
|
|
||||||
});
|
|
||||||
|
|
||||||
mock.onPost('/api/v1/truth/carousels/avatars/seen').reply(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render the Carousel', async() => {
|
|
||||||
render(<FeedCarousel />, undefined, store);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
|
|
||||||
expect(screen.queryAllByTestId('carousel-item')).toHaveLength(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle the "seen" state', async() => {
|
|
||||||
render(<FeedCarousel />, undefined, store);
|
|
||||||
|
|
||||||
// Unseen
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryAllByTestId('carousel-item')).toHaveLength(4);
|
|
||||||
});
|
|
||||||
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-accent-500');
|
|
||||||
|
|
||||||
// Selected
|
|
||||||
await userEvent.click(screen.getAllByTestId('carousel-item-avatar')[0]);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-primary-600');
|
|
||||||
});
|
|
||||||
|
|
||||||
// HACK: wait for state change
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
// Marked as seen, not selected
|
|
||||||
await userEvent.click(screen.getAllByTestId('carousel-item-avatar')[0]);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-transparent');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with 0 avatars', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').reply(200, []));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing', async() => {
|
|
||||||
render(<FeedCarousel />, undefined, store);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with a failed request to the API', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').networkError());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the error message', async() => {
|
|
||||||
render(<FeedCarousel />, undefined, store);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with multiple pages of avatars', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onGet('/api/v1/truth/carousels/avatars')
|
|
||||||
.reply(200, [
|
|
||||||
{ account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Element.prototype.getBoundingClientRect = vi.fn(() => {
|
|
||||||
return {
|
|
||||||
width: 200,
|
|
||||||
height: 120,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
toJSON: () => null,
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render the correct prev/next buttons', async() => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<FeedCarousel />, undefined, store);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('prev-page')).toHaveAttribute('disabled');
|
|
||||||
expect(screen.getByTestId('next-page')).not.toHaveAttribute('disabled');
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.click(screen.getByTestId('next-page'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('prev-page')).not.toHaveAttribute('disabled');
|
|
||||||
// expect(screen.getByTestId('next-page')).toHaveAttribute('disabled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,266 +0,0 @@
|
||||||
import clsx from 'clsx';
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { replaceHomeTimeline } from 'soapbox/actions/timelines';
|
|
||||||
import { useAppDispatch, useAppSelector, useDimensions } from 'soapbox/hooks';
|
|
||||||
import { Avatar, useCarouselAvatars, useMarkAsSeen } from 'soapbox/queries/carousels';
|
|
||||||
|
|
||||||
import { Card, HStack, Icon, Stack, Text } from '../../components/ui';
|
|
||||||
import PlaceholderAvatar from '../placeholder/components/placeholder-avatar';
|
|
||||||
|
|
||||||
const CarouselItem = React.forwardRef((
|
|
||||||
{ avatar, seen, onViewed, onPinned }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void, onPinned?: (avatar: null | Avatar) => void },
|
|
||||||
ref: React.ForwardedRef<HTMLDivElement>,
|
|
||||||
) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const markAsSeen = useMarkAsSeen();
|
|
||||||
|
|
||||||
const selectedAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId']) as string);
|
|
||||||
const isSelected = avatar.account_id === selectedAccountId;
|
|
||||||
|
|
||||||
const [isFetching, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (isFetching) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
dispatch(replaceHomeTimeline(undefined, { maxId: null }, () => setLoading(false)));
|
|
||||||
|
|
||||||
if (onPinned) {
|
|
||||||
onPinned(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (onPinned) {
|
|
||||||
onPinned(avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!seen) {
|
|
||||||
onViewed(avatar.account_id);
|
|
||||||
markAsSeen.mutate(avatar.account_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
aria-disabled={isFetching}
|
|
||||||
onClick={handleClick}
|
|
||||||
className='cursor-pointer py-4'
|
|
||||||
role='filter-feed-by-user'
|
|
||||||
data-testid='carousel-item'
|
|
||||||
>
|
|
||||||
<Stack className='h-auto w-14' space={3}>
|
|
||||||
<div className='relative mx-auto block h-12 w-12 rounded-full'>
|
|
||||||
{isSelected && (
|
|
||||||
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-primary-600/50'>
|
|
||||||
<Icon src={require('@tabler/icons/check.svg')} className='h-6 w-6 text-white' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={avatar.account_avatar}
|
|
||||||
className={clsx({
|
|
||||||
'w-12 h-12 min-w-[48px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true,
|
|
||||||
'ring-transparent': !isSelected && seen,
|
|
||||||
'ring-primary-600': isSelected,
|
|
||||||
'ring-accent-500': !seen && !isSelected,
|
|
||||||
})}
|
|
||||||
alt={avatar.acct}
|
|
||||||
data-testid='carousel-item-avatar'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text theme='muted' size='sm' truncate align='center' className='pb-0.5 leading-3'>{avatar.acct}</Text>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const FeedCarousel = () => {
|
|
||||||
const { data: avatars, isFetching, isFetched, isError } = useCarouselAvatars();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const [_ref, setContainerRef, { width }] = useDimensions();
|
|
||||||
const carouselItemRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]);
|
|
||||||
const [pageSize, setPageSize] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
const [pinnedAvatar, setPinnedAvatar] = useState<Avatar | null>(null);
|
|
||||||
|
|
||||||
const avatarsToList = useMemo(() => {
|
|
||||||
let list: (Avatar | null)[] = avatars.filter((avatar) => avatar.account_id !== pinnedAvatar?.account_id);
|
|
||||||
|
|
||||||
// If we have an Avatar pinned, let's create a new array with "null"
|
|
||||||
// in the first position of each page.
|
|
||||||
if (pinnedAvatar) {
|
|
||||||
const index = (currentPage - 1) * pageSize;
|
|
||||||
list = [
|
|
||||||
...list.slice(0, index),
|
|
||||||
null,
|
|
||||||
...list.slice(index),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}, [avatars, pinnedAvatar, currentPage, pageSize]);
|
|
||||||
|
|
||||||
const numberOfPages = Math.ceil(avatars.length / pageSize);
|
|
||||||
const widthPerAvatar = width / (Math.floor(width / 80));
|
|
||||||
|
|
||||||
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
|
||||||
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
|
||||||
|
|
||||||
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
|
|
||||||
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
|
|
||||||
|
|
||||||
const markAsSeen = (account_id: string) => {
|
|
||||||
setSeenAccountIds((prev) => [...prev, account_id]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (avatars.length > 0) {
|
|
||||||
setSeenAccountIds(
|
|
||||||
avatars
|
|
||||||
.filter((avatar) => avatar.seen !== false)
|
|
||||||
.map((avatar) => avatar.account_id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [avatars]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (width) {
|
|
||||||
setPageSize(Math.round(width / widthPerAvatar));
|
|
||||||
}
|
|
||||||
}, [width, widthPerAvatar]);
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Card variant='rounded' size='lg' data-testid='feed-carousel-error'>
|
|
||||||
<Text align='center'>
|
|
||||||
<FormattedMessage id='common.error' defaultMessage="Something isn't right. Try reloading the page." />
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFetched && avatars.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='overflow-hidden rounded-xl bg-white shadow-lg dark:bg-primary-900 dark:shadow-none'
|
|
||||||
data-testid='feed-carousel'
|
|
||||||
>
|
|
||||||
<HStack alignItems='stretch'>
|
|
||||||
<div className='z-10 flex w-8 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'>
|
|
||||||
<button
|
|
||||||
data-testid='prev-page'
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
|
||||||
disabled={!hasPrevPage}
|
|
||||||
>
|
|
||||||
<Icon src={require('@tabler/icons/chevron-left.svg')} className='h-5 w-5 text-black dark:text-white' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='relative w-full overflow-hidden'>
|
|
||||||
{pinnedAvatar ? (
|
|
||||||
<div
|
|
||||||
className='absolute inset-y-0 left-0 z-10 flex items-center justify-center bg-white dark:bg-primary-900'
|
|
||||||
style={{
|
|
||||||
width: widthPerAvatar || 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CarouselItem
|
|
||||||
avatar={pinnedAvatar}
|
|
||||||
seen={seenAccountIds?.includes(pinnedAvatar.account_id)}
|
|
||||||
onViewed={markAsSeen}
|
|
||||||
onPinned={(avatar) => setPinnedAvatar(avatar)}
|
|
||||||
ref={carouselItemRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<HStack
|
|
||||||
alignItems='center'
|
|
||||||
style={{
|
|
||||||
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
|
||||||
}}
|
|
||||||
className='transition-all duration-500 ease-out'
|
|
||||||
ref={setContainerRef}
|
|
||||||
>
|
|
||||||
{isFetching ? (
|
|
||||||
new Array(20).fill(0).map((_, idx) => (
|
|
||||||
<div
|
|
||||||
className='flex shrink-0 justify-center'
|
|
||||||
style={{ width: widthPerAvatar || 'auto' }}
|
|
||||||
key={idx}
|
|
||||||
>
|
|
||||||
<PlaceholderAvatar size={56} withText className='py-3' />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
avatarsToList.map((avatar: any, index) => (
|
|
||||||
<div
|
|
||||||
key={avatar?.account_id || index}
|
|
||||||
className='flex shrink-0 justify-center'
|
|
||||||
style={{
|
|
||||||
width: widthPerAvatar || 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{avatar === null ? (
|
|
||||||
<Stack
|
|
||||||
className='h-auto w-14 py-4'
|
|
||||||
space={3}
|
|
||||||
style={{ height: carouselItemRef.current?.clientHeight }}
|
|
||||||
>
|
|
||||||
<div className='relative mx-auto block h-16 w-16 rounded-full'>
|
|
||||||
<div className='h-16 w-16' />
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<CarouselItem
|
|
||||||
avatar={avatar}
|
|
||||||
seen={seenAccountIds?.includes(avatar.account_id)}
|
|
||||||
onPinned={(avatar) => {
|
|
||||||
setPinnedAvatar(null);
|
|
||||||
setTimeout(() => {
|
|
||||||
setPinnedAvatar(avatar);
|
|
||||||
}, 1);
|
|
||||||
}}
|
|
||||||
onViewed={markAsSeen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='z-10 flex w-8 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'>
|
|
||||||
<button
|
|
||||||
data-testid='next-page'
|
|
||||||
onClick={handleNextPage}
|
|
||||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
|
||||||
disabled={!hasNextPage}
|
|
||||||
>
|
|
||||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='h-5 w-5 text-black dark:text-white' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeedCarousel;
|
|
|
@ -6,7 +6,7 @@ import { fetchSuggestions } from 'soapbox/actions/suggestions';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
|
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
|
||||||
|
@ -15,7 +15,6 @@ const messages = defineMessages({
|
||||||
const FollowRecommendations: React.FC = () => {
|
const FollowRecommendations: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const features = useFeatures();
|
|
||||||
|
|
||||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||||
const hasMore = useAppSelector((state) => !!state.suggestions.next);
|
const hasMore = useAppSelector((state) => !!state.suggestions.next);
|
||||||
|
@ -53,25 +52,13 @@ const FollowRecommendations: React.FC = () => {
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
itemClassName='pb-4'
|
itemClassName='pb-4'
|
||||||
>
|
>
|
||||||
{features.truthSuggestions ? (
|
{suggestions.map((suggestion) => (
|
||||||
suggestions.map((suggestedProfile) => (
|
<AccountContainer
|
||||||
<AccountContainer
|
key={suggestion.account}
|
||||||
key={suggestedProfile.account}
|
id={suggestion.account}
|
||||||
id={suggestedProfile.account}
|
withAccountNote
|
||||||
withAccountNote
|
/>
|
||||||
showProfileHoverCard={false}
|
))}
|
||||||
actionAlignment='top'
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
suggestions.map((suggestion) => (
|
|
||||||
<AccountContainer
|
|
||||||
key={suggestion.account}
|
|
||||||
id={suggestion.account}
|
|
||||||
withAccountNote
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -2,9 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
|
||||||
import { expandHomeTimeline, clearFeedAccountId } from 'soapbox/actions/timelines';
|
|
||||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||||
|
@ -23,12 +21,10 @@ const HomeTimeline: React.FC = () => {
|
||||||
const polling = useRef<NodeJS.Timeout | null>(null);
|
const polling = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
||||||
const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId as string | undefined);
|
|
||||||
const currentAccountRelationship = useAppSelector(state => currentAccountId ? state.relationships.get(currentAccountId) : null);
|
|
||||||
const next = useAppSelector(state => state.timelines.get('home')?.next);
|
const next = useAppSelector(state => state.timelines.get('home')?.next);
|
||||||
|
|
||||||
const handleLoadMore = (maxId: string) => {
|
const handleLoadMore = (maxId: string) => {
|
||||||
dispatch(expandHomeTimeline({ url: next, maxId, accountId: currentAccountId }));
|
dispatch(expandHomeTimeline({ url: next, maxId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mastodon generates the feed in Redis, and can return a partial timeline
|
// Mastodon generates the feed in Redis, and can return a partial timeline
|
||||||
|
@ -51,7 +47,7 @@ const HomeTimeline: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
return dispatch(expandHomeTimeline({ accountId: currentAccountId }));
|
return dispatch(expandHomeTimeline());
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -62,25 +58,6 @@ const HomeTimeline: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [isPartial]);
|
}, [isPartial]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check to see if we still follow the user that is selected in the Feed Carousel.
|
|
||||||
if (currentAccountId) {
|
|
||||||
dispatch(fetchRelationships([currentAccountId]));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// If we unfollowed the currently selected user from the Feed Carousel,
|
|
||||||
// let's clear the feed filter and refetch fresh timeline data.
|
|
||||||
if (currentAccountRelationship && !currentAccountRelationship?.following) {
|
|
||||||
dispatch(clearFeedAccountId());
|
|
||||||
|
|
||||||
dispatch(expandHomeTimeline({}, () => {
|
|
||||||
dispatch(fetchSuggestionsForTimeline());
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [currentAccountId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||||
<PullToRefresh onRefresh={handleRefresh}>
|
<PullToRefresh onRefresh={handleRefresh}>
|
||||||
|
|
|
@ -36,13 +36,8 @@ const Timeline: React.FC<ITimeline> = ({
|
||||||
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
|
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
|
||||||
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
|
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
|
||||||
const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0);
|
const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0);
|
||||||
const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId);
|
|
||||||
|
|
||||||
const handleDequeueTimeline = () => {
|
const handleDequeueTimeline = () => {
|
||||||
if (isFilteringFeed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(dequeueTimeline(timelineId, onLoadMore));
|
dispatch(dequeueTimeline(timelineId, onLoadMore));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { uploadCompose } from 'soapbox/actions/compose';
|
import { uploadCompose } from 'soapbox/actions/compose';
|
||||||
import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel';
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
import LinkFooter from 'soapbox/features/ui/components/link-footer';
|
||||||
import {
|
import {
|
||||||
WhoToFollowPanel,
|
WhoToFollowPanel,
|
||||||
|
@ -81,8 +80,6 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{features.carousel && <FeedCarousel />}
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{!me && (
|
{!me && (
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { __stub } from 'soapbox/api';
|
|
||||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
|
||||||
|
|
||||||
import { useCarouselAvatars } from '../carousels';
|
|
||||||
|
|
||||||
describe('useCarouselAvatars', () => {
|
|
||||||
describe('with a successful query', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onGet('/api/v1/truth/carousels/avatars')
|
|
||||||
.reply(200, [
|
|
||||||
{ account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
{ account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is successful', async() => {
|
|
||||||
const { result } = renderHook(() => useCarouselAvatars());
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
|
||||||
|
|
||||||
expect(result.current.data?.length).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with an unsuccessful query', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
__stub((mock) => {
|
|
||||||
mock.onGet('/api/v1/truth/carousels/avatars').networkError();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is successful', async() => {
|
|
||||||
const { result } = renderHook(() => useCarouselAvatars());
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
|
||||||
|
|
||||||
expect(result.current.error).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,50 +0,0 @@
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
export type Avatar = {
|
|
||||||
account_id: string
|
|
||||||
account_avatar: string
|
|
||||||
acct: string
|
|
||||||
seen?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselKeys = {
|
|
||||||
avatars: ['carouselAvatars'] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
function useCarouselAvatars() {
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const getCarouselAvatars = async() => {
|
|
||||||
const { data } = await api.get('/api/v1/truth/carousels/avatars');
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = useQuery<Avatar[]>(CarouselKeys.avatars, getCarouselAvatars, {
|
|
||||||
placeholderData: [],
|
|
||||||
keepPreviousData: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const avatars = result.data;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
data: avatars || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMarkAsSeen() {
|
|
||||||
const api = useApi();
|
|
||||||
const features = useFeatures();
|
|
||||||
|
|
||||||
return useMutation(async (accountId: string) => {
|
|
||||||
if (features.carouselSeen) {
|
|
||||||
await void api.post('/api/v1/truth/carousels/avatars/seen', {
|
|
||||||
account_id: accountId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useCarouselAvatars, useMarkAsSeen };
|
|
|
@ -2,9 +2,8 @@ import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||||
import { importFetchedAccounts } from 'soapbox/actions/importer';
|
import { importFetchedAccounts } from 'soapbox/actions/importer';
|
||||||
import { SuggestedProfile } from 'soapbox/actions/suggestions';
|
|
||||||
import { getLinks } from 'soapbox/api';
|
import { getLinks } from 'soapbox/api';
|
||||||
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { PaginatedResult, removePageItem } from '../utils/queries';
|
import { PaginatedResult, removePageItem } from '../utils/queries';
|
||||||
|
|
||||||
|
@ -15,16 +14,7 @@ type Suggestion = {
|
||||||
account: IAccount
|
account: IAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
type TruthSuggestion = {
|
type Result = {
|
||||||
account_avatar: string
|
|
||||||
account_id: string
|
|
||||||
acct: string
|
|
||||||
display_name: string
|
|
||||||
note: string
|
|
||||||
verified: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result = TruthSuggestion | {
|
|
||||||
account: string
|
account: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,20 +26,9 @@ const SuggestionKeys = {
|
||||||
suggestions: ['suggestions'] as const,
|
suggestions: ['suggestions'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
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 useSuggestions = () => {
|
const useSuggestions = () => {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const features = useFeatures();
|
|
||||||
|
|
||||||
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
|
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
|
||||||
const endpoint = pageParam?.link || '/api/v2/suggestions';
|
const endpoint = pageParam?.link || '/api/v2/suggestions';
|
||||||
|
@ -69,33 +48,9 @@ const useSuggestions = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTruthSuggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
|
|
||||||
const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions';
|
|
||||||
const response = await api.get<TruthSuggestion[]>(endpoint);
|
|
||||||
const hasMore = !!response.headers.link;
|
|
||||||
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
|
||||||
|
|
||||||
const accounts = response.data.map(mapSuggestedProfileToAccount);
|
|
||||||
dispatch(importFetchedAccounts(accounts, { should_refetch: true }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: response.data.map((x) => ({ ...x, account: x.account_id })),
|
|
||||||
link: nextLink,
|
|
||||||
hasMore,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSuggestions = (pageParam: PageParam) => {
|
|
||||||
if (features.truthSuggestions) {
|
|
||||||
return getTruthSuggestions(pageParam);
|
|
||||||
} else {
|
|
||||||
return getV2Suggestions(pageParam);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = useInfiniteQuery(
|
const result = useInfiniteQuery(
|
||||||
SuggestionKeys.suggestions,
|
SuggestionKeys.suggestions,
|
||||||
({ pageParam }: any) => getSuggestions(pageParam),
|
({ pageParam }: any) => getV2Suggestions(pageParam),
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
getNextPageParam: (config) => {
|
getNextPageParam: (config) => {
|
||||||
|
|
|
@ -10,8 +10,6 @@ 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,
|
|
||||||
type SuggestedProfile,
|
|
||||||
} from 'soapbox/actions/suggestions';
|
} from 'soapbox/actions/suggestions';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
@ -55,14 +53,6 @@ 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));
|
||||||
};
|
};
|
||||||
|
@ -80,8 +70,6 @@ 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);
|
||||||
|
|
|
@ -28,9 +28,7 @@ import {
|
||||||
TIMELINE_DEQUEUE,
|
TIMELINE_DEQUEUE,
|
||||||
MAX_QUEUED_ITEMS,
|
MAX_QUEUED_ITEMS,
|
||||||
TIMELINE_SCROLL_TOP,
|
TIMELINE_SCROLL_TOP,
|
||||||
TIMELINE_REPLACE,
|
|
||||||
TIMELINE_INSERT,
|
TIMELINE_INSERT,
|
||||||
TIMELINE_CLEAR_FEED_ACCOUNT_ID,
|
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
@ -49,7 +47,6 @@ const TimelineRecord = ImmutableRecord({
|
||||||
prev: undefined as string | undefined,
|
prev: undefined as string | undefined,
|
||||||
items: ImmutableOrderedSet<string>(),
|
items: ImmutableOrderedSet<string>(),
|
||||||
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
||||||
feedAccountId: null,
|
|
||||||
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
|
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
|
||||||
loadingFailed: false,
|
loadingFailed: false,
|
||||||
isPartial: false,
|
isPartial: false,
|
||||||
|
@ -363,12 +360,6 @@ export default function timelines(state: State = initialState, action: AnyAction
|
||||||
return timelineConnect(state, action.timeline);
|
return timelineConnect(state, action.timeline);
|
||||||
case TIMELINE_DISCONNECT:
|
case TIMELINE_DISCONNECT:
|
||||||
return timelineDisconnect(state, action.timeline);
|
return timelineDisconnect(state, action.timeline);
|
||||||
case TIMELINE_REPLACE:
|
|
||||||
return state
|
|
||||||
.update('home', TimelineRecord(), timeline => timeline.withMutations(timeline => {
|
|
||||||
timeline.set('items', ImmutableOrderedSet([]));
|
|
||||||
}))
|
|
||||||
.update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', action.accountId));
|
|
||||||
case TIMELINE_INSERT:
|
case TIMELINE_INSERT:
|
||||||
return state.update(action.timeline, TimelineRecord(), timeline => timeline.withMutations(timeline => {
|
return state.update(action.timeline, TimelineRecord(), timeline => timeline.withMutations(timeline => {
|
||||||
timeline.update('items', oldIds => {
|
timeline.update('items', oldIds => {
|
||||||
|
@ -386,8 +377,6 @@ export default function timelines(state: State = initialState, action: AnyAction
|
||||||
return ImmutableOrderedSet(oldIdsArray);
|
return ImmutableOrderedSet(oldIdsArray);
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
case TIMELINE_CLEAR_FEED_ACCOUNT_ID:
|
|
||||||
return state.update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', null));
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -247,19 +247,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
v.software === PLEROMA,
|
v.software === PLEROMA,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show the Feed Carousel for suggested Statuses.
|
|
||||||
* @see GET /api/v1/truth/carousels/avatars
|
|
||||||
* @see GET /api/v1/truth/carousels/suggestions
|
|
||||||
*/
|
|
||||||
carousel: v.software === TRUTHSOCIAL,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ability to mark a carousel avatar as "seen."
|
|
||||||
* @see POST /api/v1/truth/carousels/avatars/seen
|
|
||||||
*/
|
|
||||||
carouselSeen: v.software === TRUTHSOCIAL,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ability to accept a chat.
|
* Ability to accept a chat.
|
||||||
* POST /api/v1/pleroma/chats/:id/accept
|
* POST /api/v1/pleroma/chats/:id/accept
|
||||||
|
@ -960,11 +947,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
truthPolicies: v.software === TRUTHSOCIAL,
|
truthPolicies: 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