Merge branch 'rm-carousel' into 'main'
Remove Truth Social feed carousel See merge request soapbox-pub/soapbox!2739
This commit is contained in:
commit
52f9339050
|
@ -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 { 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_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
|
||||
|
||||
const SUGGESTIONS_TRUTH_FETCH_REQUEST = 'SUGGESTIONS_TRUTH_FETCH_REQUEST';
|
||||
const SUGGESTIONS_TRUTH_FETCH_SUCCESS = 'SUGGESTIONS_TRUTH_FETCH_SUCCESS';
|
||||
const SUGGESTIONS_TRUTH_FETCH_FAIL = 'SUGGESTIONS_TRUTH_FETCH_FAIL';
|
||||
|
||||
const fetchSuggestionsV1 = (params: Record<string, any> = {}) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true });
|
||||
|
@ -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 }) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
|
@ -110,14 +62,7 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
|
|||
|
||||
if (!me) return null;
|
||||
|
||||
if (features.truthSuggestions) {
|
||||
return dispatch(fetchTruthSuggestions(params))
|
||||
.then((suggestions: APIEntity[]) => {
|
||||
const accountIds = suggestions.map((account) => account.account_id);
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
})
|
||||
.catch(() => { });
|
||||
} else if (features.suggestionsV2) {
|
||||
if (features.suggestionsV2) {
|
||||
return dispatch(fetchSuggestionsV2(params))
|
||||
.then((suggestions: APIEntity[]) => {
|
||||
const accountIds = suggestions.map(({ account }) => account.id);
|
||||
|
@ -161,9 +106,6 @@ export {
|
|||
SUGGESTIONS_V2_FETCH_REQUEST,
|
||||
SUGGESTIONS_V2_FETCH_SUCCESS,
|
||||
SUGGESTIONS_V2_FETCH_FAIL,
|
||||
SUGGESTIONS_TRUTH_FETCH_REQUEST,
|
||||
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
|
||||
SUGGESTIONS_TRUTH_FETCH_FAIL,
|
||||
fetchSuggestionsV1,
|
||||
fetchSuggestionsV2,
|
||||
fetchSuggestions,
|
||||
|
|
|
@ -27,9 +27,7 @@ const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL' as const;
|
|||
const TIMELINE_CONNECT = 'TIMELINE_CONNECT' 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_CLEAR_FEED_ACCOUNT_ID = 'TIMELINE_CLEAR_FEED_ACCOUNT_ID' as const;
|
||||
|
||||
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) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
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 {
|
||||
accountId?: string
|
||||
maxId?: string
|
||||
url?: string
|
||||
}
|
||||
|
@ -220,19 +203,14 @@ interface HomeTimelineParams {
|
|||
with_muted?: boolean
|
||||
}
|
||||
|
||||
const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
||||
const endpoint = url || (accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home');
|
||||
const expandHomeTimeline = ({ url, maxId }: ExpandHomeTimelineOpts = {}, done = noOp) => {
|
||||
const endpoint = url || '/api/v1/timelines/home';
|
||||
const params: HomeTimelineParams = {};
|
||||
|
||||
if (!url && maxId) {
|
||||
params.max_id = maxId;
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
params.exclude_replies = true;
|
||||
params.with_muted = true;
|
||||
}
|
||||
|
||||
return expandTimeline('home', endpoint, params, done);
|
||||
};
|
||||
|
||||
|
@ -333,10 +311,6 @@ const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: ()
|
|||
dispatch({ type: TIMELINE_INSERT, timeline: 'home' });
|
||||
};
|
||||
|
||||
const clearFeedAccountId = () => (dispatch: AppDispatch, _getState: () => RootState) => {
|
||||
dispatch({ type: TIMELINE_CLEAR_FEED_ACCOUNT_ID });
|
||||
};
|
||||
|
||||
// TODO: other actions
|
||||
type TimelineAction = TimelineDeleteAction;
|
||||
|
||||
|
@ -352,8 +326,6 @@ export {
|
|||
TIMELINE_EXPAND_FAIL,
|
||||
TIMELINE_CONNECT,
|
||||
TIMELINE_DISCONNECT,
|
||||
TIMELINE_REPLACE,
|
||||
TIMELINE_CLEAR_FEED_ACCOUNT_ID,
|
||||
TIMELINE_INSERT,
|
||||
MAX_QUEUED_ITEMS,
|
||||
processTimelineUpdate,
|
||||
|
@ -363,7 +335,6 @@ export {
|
|||
deleteFromTimelines,
|
||||
clearTimeline,
|
||||
expandTimeline,
|
||||
replaceHomeTimeline,
|
||||
expandHomeTimeline,
|
||||
expandPublicTimeline,
|
||||
expandRemoteTimeline,
|
||||
|
@ -385,6 +356,5 @@ export {
|
|||
disconnectTimeline,
|
||||
scrollTopTimeline,
|
||||
insertSuggestionsIntoTimeline,
|
||||
clearFeedAccountId,
|
||||
type TimelineAction,
|
||||
};
|
||||
|
|
|
@ -17,10 +17,10 @@ const fetchTrendingStatuses = () =>
|
|||
const instance = state.instance;
|
||||
const features = getFeatures(instance);
|
||||
|
||||
if (!features.trendingStatuses && !features.trendingTruths) return;
|
||||
if (!features.trendingStatuses) return;
|
||||
|
||||
dispatch({ type: TRENDING_STATUSES_FETCH_REQUEST });
|
||||
return api(getState).get(features.trendingTruths ? '/api/v1/truth/trending/truths' : '/api/v1/trends/statuses').then(({ data: statuses }) => {
|
||||
return api(getState).get('/api/v1/trends/statuses').then(({ data: statuses }) => {
|
||||
dispatch(importFetchedStatuses(statuses));
|
||||
dispatch({ type: TRENDING_STATUSES_FETCH_SUCCESS, statuses });
|
||||
return statuses;
|
||||
|
|
|
@ -8,8 +8,6 @@ import { cancelReplyCompose } from 'soapbox/actions/compose';
|
|||
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||
import { openModal, closeModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch, usePrevious } from 'soapbox/hooks';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { IPolicy, PolicyKeys } from 'soapbox/queries/policies';
|
||||
|
||||
import type { ModalType } from 'soapbox/features/ui/components/modal-root';
|
||||
import type { ReducerCompose } from 'soapbox/reducers/compose';
|
||||
|
@ -114,15 +112,6 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
|
|||
}));
|
||||
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
|
||||
dispatch(closeModal('CONFIRM'));
|
||||
} else if (type === 'POLICY') {
|
||||
// If the user has not accepted the Policy, prevent them
|
||||
// from closing the Modal.
|
||||
const pendingPolicy = queryClient.getQueryData(PolicyKeys.policy) as IPolicy;
|
||||
if (pendingPolicy?.pending_policy_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
|
|
|
@ -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 { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
|
||||
|
@ -15,7 +15,6 @@ const messages = defineMessages({
|
|||
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);
|
||||
|
@ -53,25 +52,13 @@ const FollowRecommendations: React.FC = () => {
|
|||
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
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{suggestions.map((suggestion) => (
|
||||
<AccountContainer
|
||||
key={suggestion.account}
|
||||
id={suggestion.account}
|
||||
withAccountNote
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
</Column>
|
||||
|
|
|
@ -2,9 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||
import { expandHomeTimeline, clearFeedAccountId } from 'soapbox/actions/timelines';
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||
|
@ -23,12 +21,10 @@ const HomeTimeline: React.FC = () => {
|
|||
const polling = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
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 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
|
||||
|
@ -51,7 +47,7 @@ const HomeTimeline: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(expandHomeTimeline({ accountId: currentAccountId }));
|
||||
return dispatch(expandHomeTimeline());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -62,25 +58,6 @@ const HomeTimeline: React.FC = () => {
|
|||
};
|
||||
}, [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 (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
|
|
|
@ -30,7 +30,6 @@ import {
|
|||
MentionsModal,
|
||||
MissingDescriptionModal,
|
||||
MuteModal,
|
||||
PolicyModal,
|
||||
ReactionsModal,
|
||||
ReblogsModal,
|
||||
ReplyMentionsModal,
|
||||
|
@ -75,7 +74,6 @@ const MODAL_COMPONENTS = {
|
|||
'MENTIONS': MentionsModal,
|
||||
'MISSING_DESCRIPTION': MissingDescriptionModal,
|
||||
'MUTE': MuteModal,
|
||||
'POLICY': PolicyModal,
|
||||
'REACTIONS': ReactionsModal,
|
||||
'REBLOGS': ReblogsModal,
|
||||
'REPLY_MENTIONS': ReplyMentionsModal,
|
||||
|
|
|
@ -1,162 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Text, Button, Modal, Stack, HStack } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { usePendingPolicy, useAcceptPolicy } from 'soapbox/queries/policies';
|
||||
|
||||
interface IPolicyModal {
|
||||
onClose: (type: string) => void
|
||||
}
|
||||
|
||||
const DirectMessageUpdates = () => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
const { links } = soapboxConfig;
|
||||
|
||||
return (
|
||||
<Stack space={3}>
|
||||
<Stack space={4} className='rounded-lg border-2 border-solid border-primary-200 p-4 dark:border-primary-800'>
|
||||
<HStack alignItems='center' space={3}>
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path d='M0 22.5306C0 10.0873 10.0873 0 22.5306 0H26.4828C38.3664 0 48 9.6336 48 21.5172V21.5172C48 36.1433 36.1433 48 21.5172 48H18.4615C8.26551 48 0 39.7345 0 29.5385V22.5306Z' fill='url(#paint0_linear_2190_131524)' fillOpacity='0.2' />
|
||||
<path fillRule='evenodd' clipRule='evenodd' d='M14.0001 19C14.0001 17.3431 15.3433 16 17.0001 16H31.0001C32.657 16 34.0001 17.3431 34.0001 19V19.9845C34.0002 19.9942 34.0002 20.004 34.0001 20.0137V29C34.0001 30.6569 32.657 32 31.0001 32H17.0001C15.3433 32 14.0001 30.6569 14.0001 29V20.0137C14 20.004 14 19.9942 14.0001 19.9845V19ZM16.0001 21.8685V29C16.0001 29.5523 16.4478 30 17.0001 30H31.0001C31.5524 30 32.0001 29.5523 32.0001 29V21.8685L25.6642 26.0925C24.6565 26.7642 23.3437 26.7642 22.336 26.0925L16.0001 21.8685ZM32.0001 19.4648L24.5548 24.4283C24.2189 24.6523 23.7813 24.6523 23.4454 24.4283L16.0001 19.4648V19C16.0001 18.4477 16.4478 18 17.0001 18H31.0001C31.5524 18 32.0001 18.4477 32.0001 19V19.4648Z' fill='#818CF8' />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_2190_131524' x1='0' y1='0' x2='43.6184' y2='-3.69691' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopColor='#B8A3F9' />
|
||||
<stop offset='1' stopColor='#9BD5FF' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<Text weight='bold'>
|
||||
Direct Messaging
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text theme='muted'>
|
||||
Yes, direct messages are finally here!
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
Bring one-on-one conversations from your Feed to your DMs with
|
||||
messages that automatically delete for your privacy.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack space={4} className='rounded-lg border-2 border-solid border-primary-200 p-4 dark:border-primary-800'>
|
||||
<HStack alignItems='center' space={3}>
|
||||
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path d='M0 25.7561C0 22.2672 0 20.5228 0.197492 19.0588C1.52172 9.24259 9.24259 1.52172 19.0588 0.197492C20.5228 0 22.2672 0 25.7561 0H30.1176C39.9938 0 48 8.0062 48 17.8824C48 34.5159 34.5159 48 17.8824 48H15.3192C15.0228 48 14.8747 48 14.7494 47.9979C6.66132 47.8627 0.137263 41.3387 0.0020943 33.2506C0 33.1253 0 32.9772 0 32.6808V25.7561Z' fill='url(#paint0_linear_2190_131532)' fillOpacity='0.2' />
|
||||
<path fillRule='evenodd' clipRule='evenodd' d='M23.9999 14C24.5522 14 24.9999 14.4477 24.9999 15V16C24.9999 16.5523 24.5522 17 23.9999 17C23.4477 17 22.9999 16.5523 22.9999 16V15C22.9999 14.4477 23.4477 14 23.9999 14ZM16.9289 16.9289C17.3194 16.5384 17.9526 16.5384 18.3431 16.9289L19.0502 17.636C19.4407 18.0266 19.4407 18.6597 19.0502 19.0503C18.6597 19.4408 18.0265 19.4408 17.636 19.0503L16.9289 18.3431C16.5384 17.9526 16.5384 17.3195 16.9289 16.9289ZM31.071 16.9289C31.4615 17.3195 31.4615 17.9526 31.071 18.3431L30.3639 19.0503C29.9734 19.4408 29.3402 19.4408 28.9497 19.0503C28.5592 18.6597 28.5592 18.0266 28.9497 17.636L29.6568 16.9289C30.0473 16.5384 30.6805 16.5384 31.071 16.9289ZM21.1715 21.1716C19.6094 22.7337 19.6094 25.2664 21.1715 26.8285L21.7186 27.3755C21.9116 27.5686 22.0848 27.7778 22.2367 28H25.7632C25.9151 27.7778 26.0882 27.5686 26.2813 27.3755L26.8284 26.8285C28.3905 25.2664 28.3905 22.7337 26.8284 21.1716C25.2663 19.6095 22.7336 19.6095 21.1715 21.1716ZM27.2448 29.4187C27.3586 29.188 27.5101 28.9751 27.6955 28.7898L28.2426 28.2427C30.5857 25.8995 30.5857 22.1005 28.2426 19.7574C25.8994 17.4142 22.1005 17.4142 19.7573 19.7574C17.4142 22.1005 17.4142 25.8995 19.7573 28.2427L20.3044 28.7898C20.4898 28.9751 20.6413 29.188 20.7551 29.4187C20.7601 29.4295 20.7653 29.4403 20.7706 29.4509C20.9202 29.7661 20.9999 30.1134 20.9999 30.469V31C20.9999 32.6569 22.3431 34 23.9999 34C25.6568 34 26.9999 32.6569 26.9999 31V30.469C26.9999 30.1134 27.0797 29.7661 27.2292 29.4509C27.2346 29.4403 27.2398 29.4295 27.2448 29.4187ZM25.0251 30H22.9748C22.9915 30.155 22.9999 30.3116 22.9999 30.469V31C22.9999 31.5523 23.4477 32 23.9999 32C24.5522 32 24.9999 31.5523 24.9999 31V30.469C24.9999 30.3116 25.0084 30.155 25.0251 30ZM14 23.9999C14 23.4477 14.4477 22.9999 15 22.9999H16C16.5523 22.9999 17 23.4477 17 23.9999C17 24.5522 16.5523 24.9999 16 24.9999H15C14.4477 24.9999 14 24.5522 14 23.9999ZM31 23.9999C31 23.4477 31.4477 22.9999 32 22.9999H33C33.5523 22.9999 34 23.4477 34 23.9999C34 24.5522 33.5523 24.9999 33 24.9999H32C31.4477 24.9999 31 24.5522 31 23.9999Z' fill='#818CF8' />
|
||||
<defs>
|
||||
<linearGradient id='paint0_linear_2190_131532' x1='0' y1='0' x2='43.6184' y2='-3.69691' gradientUnits='userSpaceOnUse'>
|
||||
<stop stopColor='#B8A3F9' />
|
||||
<stop offset='1' stopColor='#9BD5FF' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<Text weight='bold'>Privacy Policy Updates</Text>
|
||||
</HStack>
|
||||
|
||||
<ul className='space-y-2'>
|
||||
<li className='flex items-center space-x-2'>
|
||||
<span className='flex h-8 w-8 items-center justify-center rounded-full border-2 border-solid border-gray-200 text-sm font-bold text-primary-500 dark:border-gray-800 dark:text-primary-300'>
|
||||
1
|
||||
</span>
|
||||
|
||||
<Text theme='muted'>Consolidates previously-separate policies</Text>
|
||||
</li>
|
||||
<li className='flex items-center space-x-2'>
|
||||
<span className='flex h-8 w-8 items-center justify-center rounded-full border-2 border-solid border-gray-200 text-sm font-bold text-primary-500 dark:border-gray-800 dark:text-primary-300'>
|
||||
2
|
||||
</span>
|
||||
|
||||
<Text theme='muted'>Reaffirms jurisdiction-specific requirements</Text>
|
||||
</li>
|
||||
<li className='flex items-center space-x-2'>
|
||||
<span className='flex h-8 w-8 items-center justify-center rounded-full border-2 border-solid border-gray-200 text-sm font-bold text-primary-500 dark:border-gray-800 dark:text-primary-300'>
|
||||
3
|
||||
</span>
|
||||
|
||||
<Text theme='muted'>Introduces updates regarding ads and direct messages</Text>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{links.get('privacyPolicy') ? (
|
||||
<a
|
||||
className='text-center font-bold text-primary-600 hover:underline dark:text-accent-blue'
|
||||
href={links.get('privacyPolicy')}
|
||||
target='_blank'
|
||||
>
|
||||
View Privacy Policy
|
||||
</a>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const supportedPolicyIds = ['1'];
|
||||
|
||||
/** Modal to show privacy policy changes that need confirmation. */
|
||||
const PolicyModal: React.FC<IPolicyModal> = ({ onClose }) => {
|
||||
const acceptPolicy = useAcceptPolicy();
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
|
||||
const { data: pendingPolicy, isLoading } = usePendingPolicy();
|
||||
|
||||
const renderPolicyBody = () => {
|
||||
switch (pendingPolicy?.pending_policy_id) {
|
||||
case '1':
|
||||
return <DirectMessageUpdates />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptPolicy.mutate({
|
||||
policy_id: pendingPolicy?.pending_policy_id as string,
|
||||
}, {
|
||||
onSuccess() {
|
||||
onClose('POLICY');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading || !pendingPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title='Updates'>
|
||||
<Stack space={4}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='modals.policy.updateTitle'
|
||||
defaultMessage='You’ve scored the latest version of {siteTitle}! Take a moment to review the exciting new things we’ve been working on.'
|
||||
values={{ siteTitle: instance.title }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{renderPolicyBody()}
|
||||
|
||||
<Button
|
||||
theme='primary'
|
||||
size='lg'
|
||||
block
|
||||
onClick={handleAccept}
|
||||
disabled={acceptPolicy.isLoading}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='modals.policy.submit'
|
||||
defaultMessage='Accept & Continue'
|
||||
/>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export { PolicyModal as default, supportedPolicyIds };
|
|
@ -36,13 +36,8 @@ const Timeline: React.FC<ITimeline> = ({
|
|||
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
|
||||
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
|
||||
const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0);
|
||||
const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId);
|
||||
|
||||
const handleDequeueTimeline = () => {
|
||||
if (isFilteringFeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(dequeueTimeline(timelineId, onLoadMore));
|
||||
};
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
|||
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||
import { fetchFilters } from 'soapbox/actions/filters';
|
||||
import { fetchMarker } from 'soapbox/actions/markers';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { expandNotifications } from 'soapbox/actions/notifications';
|
||||
import { register as registerPushNotifications } from 'soapbox/actions/push-notifications';
|
||||
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses';
|
||||
|
@ -38,13 +37,11 @@ import ProfilePage from 'soapbox/pages/profile-page';
|
|||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
import SearchPage from 'soapbox/pages/search-page';
|
||||
import StatusPage from 'soapbox/pages/status-page';
|
||||
import { usePendingPolicy } from 'soapbox/queries/policies';
|
||||
import { getVapidKey } from 'soapbox/utils/auth';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import BackgroundShapes from './components/background-shapes';
|
||||
import FloatingActionButton from './components/floating-action-button';
|
||||
import { supportedPolicyIds } from './components/modals/policy-modal';
|
||||
import Navbar from './components/navbar';
|
||||
import BundleContainer from './containers/bundle-container';
|
||||
import {
|
||||
|
@ -389,7 +386,6 @@ interface IUI {
|
|||
const UI: React.FC<IUI> = ({ children }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const { data: pendingPolicy } = usePendingPolicy();
|
||||
const node = useRef<HTMLDivElement | null>(null);
|
||||
const me = useAppSelector(state => state.me);
|
||||
const { account } = useOwnAccount();
|
||||
|
@ -483,14 +479,6 @@ const UI: React.FC<IUI> = ({ children }) => {
|
|||
dispatch(registerPushNotifications());
|
||||
}, [vapidKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account && pendingPolicy && supportedPolicyIds.includes(pendingPolicy.pending_policy_id)) {
|
||||
setTimeout(() => {
|
||||
dispatch(openModal('POLICY'));
|
||||
}, 500);
|
||||
}
|
||||
}, [pendingPolicy, !!account]);
|
||||
|
||||
const shouldHideFAB = (): boolean => {
|
||||
const path = location.pathname;
|
||||
return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/));
|
||||
|
|
|
@ -122,10 +122,6 @@ export function AccountModerationModal() {
|
|||
return import('../components/modals/account-moderation-modal/account-moderation-modal');
|
||||
}
|
||||
|
||||
export function PolicyModal() {
|
||||
return import('../components/modals/policy-modal');
|
||||
}
|
||||
|
||||
export function MediaGallery() {
|
||||
return import('../../../components/media-gallery');
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import { useIntl } from 'react-intl';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 {
|
||||
WhoToFollowPanel,
|
||||
|
@ -81,8 +80,6 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{features.carousel && <FeedCarousel />}
|
||||
|
||||
{children}
|
||||
|
||||
{!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 };
|
|
@ -1,47 +0,0 @@
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import { queryClient } from './client';
|
||||
|
||||
export interface IPolicy {
|
||||
pending_policy_id: string
|
||||
}
|
||||
|
||||
const PolicyKeys = {
|
||||
policy: ['policy'] as const,
|
||||
};
|
||||
|
||||
function usePendingPolicy() {
|
||||
const api = useApi();
|
||||
const { account } = useOwnAccount();
|
||||
const features = useFeatures();
|
||||
|
||||
const getPolicy = async() => {
|
||||
const { data } = await api.get<IPolicy>('/api/v1/truth/policies/pending');
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery(PolicyKeys.policy, getPolicy, {
|
||||
retry: 3,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: Infinity,
|
||||
enabled: !!account && features.truthPolicies,
|
||||
});
|
||||
}
|
||||
|
||||
function useAcceptPolicy() {
|
||||
const api = useApi();
|
||||
|
||||
return useMutation((
|
||||
{ policy_id }: { policy_id: string },
|
||||
) => api.patch(`/api/v1/truth/policies/${policy_id}/accept`), {
|
||||
onSuccess() {
|
||||
queryClient.setQueryData(PolicyKeys.policy, {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { usePendingPolicy, useAcceptPolicy, PolicyKeys };
|
|
@ -2,9 +2,8 @@ import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
|||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { importFetchedAccounts } from 'soapbox/actions/importer';
|
||||
import { SuggestedProfile } from 'soapbox/actions/suggestions';
|
||||
import { getLinks } from 'soapbox/api';
|
||||
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import { PaginatedResult, removePageItem } from '../utils/queries';
|
||||
|
||||
|
@ -15,16 +14,7 @@ type Suggestion = {
|
|||
account: IAccount
|
||||
}
|
||||
|
||||
type TruthSuggestion = {
|
||||
account_avatar: string
|
||||
account_id: string
|
||||
acct: string
|
||||
display_name: string
|
||||
note: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
type Result = TruthSuggestion | {
|
||||
type Result = {
|
||||
account: string
|
||||
}
|
||||
|
||||
|
@ -36,20 +26,9 @@ const SuggestionKeys = {
|
|||
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 api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
|
||||
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(
|
||||
SuggestionKeys.suggestions,
|
||||
({ pageParam }: any) => getSuggestions(pageParam),
|
||||
({ pageParam }: any) => getV2Suggestions(pageParam),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
getNextPageParam: (config) => {
|
||||
|
|
|
@ -10,8 +10,6 @@ import {
|
|||
SUGGESTIONS_V2_FETCH_REQUEST,
|
||||
SUGGESTIONS_V2_FETCH_SUCCESS,
|
||||
SUGGESTIONS_V2_FETCH_FAIL,
|
||||
SUGGESTIONS_TRUTH_FETCH_SUCCESS,
|
||||
type SuggestedProfile,
|
||||
} from 'soapbox/actions/suggestions';
|
||||
|
||||
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) => {
|
||||
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);
|
||||
case SUGGESTIONS_V2_FETCH_SUCCESS:
|
||||
return importSuggestions(state, action.suggestions, action.next);
|
||||
case SUGGESTIONS_TRUTH_FETCH_SUCCESS:
|
||||
return importTruthSuggestions(state, action.suggestions, action.next);
|
||||
case SUGGESTIONS_FETCH_FAIL:
|
||||
case SUGGESTIONS_V2_FETCH_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
|
|
|
@ -28,9 +28,7 @@ import {
|
|||
TIMELINE_DEQUEUE,
|
||||
MAX_QUEUED_ITEMS,
|
||||
TIMELINE_SCROLL_TOP,
|
||||
TIMELINE_REPLACE,
|
||||
TIMELINE_INSERT,
|
||||
TIMELINE_CLEAR_FEED_ACCOUNT_ID,
|
||||
} from '../actions/timelines';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
|
@ -49,7 +47,6 @@ const TimelineRecord = ImmutableRecord({
|
|||
prev: undefined as string | undefined,
|
||||
items: ImmutableOrderedSet<string>(),
|
||||
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
||||
feedAccountId: null,
|
||||
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
|
||||
loadingFailed: false,
|
||||
isPartial: false,
|
||||
|
@ -363,12 +360,6 @@ export default function timelines(state: State = initialState, action: AnyAction
|
|||
return timelineConnect(state, action.timeline);
|
||||
case TIMELINE_DISCONNECT:
|
||||
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:
|
||||
return state.update(action.timeline, TimelineRecord(), timeline => timeline.withMutations(timeline => {
|
||||
timeline.update('items', oldIds => {
|
||||
|
@ -386,8 +377,6 @@ export default function timelines(state: State = initialState, action: AnyAction
|
|||
return ImmutableOrderedSet(oldIdsArray);
|
||||
});
|
||||
}));
|
||||
case TIMELINE_CLEAR_FEED_ACCOUNT_ID:
|
||||
return state.update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', null));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -247,19 +247,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
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.
|
||||
* POST /api/v1/pleroma/chats/:id/accept
|
||||
|
@ -936,12 +923,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
|
||||
]),
|
||||
|
||||
/**
|
||||
* Truth Social trending statuses API.
|
||||
* @see GET /api/v1/truth/trending/truths
|
||||
*/
|
||||
trendingTruths: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can display trending hashtags.
|
||||
* @see GET /api/v1/trends
|
||||
|
@ -953,18 +934,6 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
v.software === DITTO,
|
||||
]),
|
||||
|
||||
/**
|
||||
* Truth Social policies.
|
||||
* @see GET /api/v1/truth/policies/pending
|
||||
* @see PATCH /api/v1/truth/policies/:policyId/accept
|
||||
*/
|
||||
truthPolicies: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Supports Truth suggestions.
|
||||
*/
|
||||
truthSuggestions: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Whether the backend allows adding users you don't follow to lists.
|
||||
* @see POST /api/v1/lists/:id/accounts
|
||||
|
|
Loading…
Reference in New Issue