Merge branch 'local-suggested' into 'main'

Add LatestAccountsPanel

See merge request soapbox-pub/soapbox!3322
remove-immutable-compose^2
Alex Gleason 2025-02-02 04:20:32 +00:00
commit a607c83feb
11 zmienionych plików z 132 dodań i 54 usunięć

Wyświetl plik

@ -80,7 +80,7 @@ const fetchSuggestions = (params: Record<string, any> = { limit: 50 }) =>
};
const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => {
dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline()));
dispatch(insertSuggestionsIntoTimeline());
};
const dismissSuggestion = (accountId: string) =>

Wyświetl plik

@ -7,7 +7,8 @@ import HStack from 'soapbox/components/ui/hstack.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import VerificationBadge from 'soapbox/components/verification-badge.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useSuggestions } from 'soapbox/queries/suggestions.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import ActionButton from '../ui/components/action-button.tsx';
@ -66,18 +67,19 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
);
};
interface IFeedSuggesetions {
interface IFeedSuggestions {
statusId: string;
onMoveUp?: (statusId: string, featured?: boolean) => void;
onMoveDown?: (statusId: string, featured?: boolean) => void;
}
const FeedSuggestions: React.FC<IFeedSuggesetions> = ({ statusId, onMoveUp, onMoveDown }) => {
const FeedSuggestions: React.FC<IFeedSuggestions> = ({ statusId, onMoveUp, onMoveDown }) => {
const intl = useIntl();
const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const features = useFeatures();
if (!isLoading && suggestedProfiles.size === 0) return null;
const { data: suggestedProfiles, isLoading } = useSuggestions({ local: features.suggestionsLocal });
if (!isLoading && suggestedProfiles.length === 0) return null;
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
if (onMoveUp) {

Wyświetl plik

@ -1,41 +1,33 @@
import { debounce } from 'es-toolkit';
import { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchSuggestions } from 'soapbox/actions/suggestions.ts';
import ScrollableList from 'soapbox/components/scrollable-list.tsx';
import { Column } from 'soapbox/components/ui/column.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useSuggestions } from 'soapbox/queries/suggestions.ts';
const messages = defineMessages({
heading: { id: 'follow_recommendations.heading', defaultMessage: 'Suggested Profiles' },
});
const FollowRecommendations: React.FC = () => {
const dispatch = useAppDispatch();
interface IFollowRecommendations {
local?: boolean;
}
const FollowRecommendations: React.FC<IFollowRecommendations> = ({ local = false }) => {
const intl = useIntl();
const suggestions = useAppSelector((state) => state.suggestions.items);
const hasMore = useAppSelector((state) => !!state.suggestions.next);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const { data: suggestions, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = useSuggestions({ local });
const handleLoadMore = debounce(() => {
if (isLoading) {
return null;
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, 1000);
return dispatch(fetchSuggestions({ limit: 20 }));
}, 300);
useEffect(() => {
dispatch(fetchSuggestions({ limit: 20 }));
}, []);
if (suggestions.size === 0 && !isLoading) {
if (suggestions.length === 0 && !isLoading) {
return (
<Column label={intl.formatMessage(messages.heading)}>
<Text align='center'>
@ -52,7 +44,7 @@ const FollowRecommendations: React.FC = () => {
isLoading={isLoading}
scrollKey='suggestions'
onLoadMore={handleLoadMore}
hasMore={hasMore}
hasMore={hasNextPage}
itemClassName='pb-4'
>
{suggestions.map((suggestion) => (

Wyświetl plik

@ -0,0 +1,68 @@
import xIcon from '@tabler/icons/outline/x.svg';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import Text from 'soapbox/components/ui/text.tsx';
import Widget from 'soapbox/components/ui/widget.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import PlaceholderSidebarSuggestions from 'soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { useDismissSuggestion, useSuggestions } from 'soapbox/queries/suggestions.ts';
import type { Account as AccountEntity } from 'soapbox/types/entities.ts';
const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
});
interface ILatestAccountsPanel {
limit: number;
}
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit }) => {
const intl = useIntl();
const { account } = useOwnAccount();
const { data: suggestions, isFetching } = useSuggestions({ local: true });
const dismissSuggestion = useDismissSuggestion();
const suggestionsToRender = suggestions.slice(0, limit);
const handleDismiss = (account: AccountEntity) => {
dismissSuggestion.mutate(account.id);
};
if (!isFetching && !suggestions.length) {
return null;
}
return (
<Widget
title={<FormattedMessage id='latest_accounts.title' defaultMessage='Latest Accounts' />}
action={
<Link className='text-right' to='/suggestions/local'>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
<FormattedMessage id='feed_suggestions.view_all' defaultMessage='View all' />
</Text>
</Link>
}
>
{isFetching ? (
<PlaceholderSidebarSuggestions limit={limit} />
) : (
suggestionsToRender.map((suggestion: any) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={xIcon}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={account ? handleDismiss : undefined}
/>
))
)}
</Widget>
);
};
export default LatestAccountsPanel;

Wyświetl plik

@ -262,8 +262,9 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
<WrappedRoute path='/search' page={SearchPage} component={Search} content={children} publicRoute />
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
{features.suggestionsLocal && <WrappedRoute path='/suggestions/local' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} componentParams={{ local: true }} />}
{features.suggestions && <WrappedRoute path='/suggestions' exact publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
{features.profileDirectory && <WrappedRoute path='/directory' exact publicRoute page={DefaultPage} component={Directory} content={children} />}
{features.events && <WrappedRoute path='/events' page={EventsPage} component={Events} content={children} />}
{features.chats && <WrappedRoute path='/chats' exact page={ChatsPage} component={ChatIndex} content={children} />}

Wyświetl plik

@ -93,7 +93,7 @@ export const ProfileFieldsPanel = lazy(() => import('soapbox/features/ui/compone
export const PinnedAccountsPanel = lazy(() => import('soapbox/features/ui/components/pinned-accounts-panel.tsx'));
export const InstanceInfoPanel = lazy(() => import('soapbox/features/ui/components/instance-info-panel.tsx'));
export const InstanceModerationPanel = lazy(() => import('soapbox/features/ui/components/instance-moderation-panel.tsx'));
export const LatestAccountsPanel = lazy(() => import('soapbox/features/admin/components/latest-accounts-panel.tsx'));
export const LatestAdminAccountsPanel = lazy(() => import('soapbox/features/admin/components/latest-accounts-panel.tsx'));
export const SidebarMenu = lazy(() => import('soapbox/components/sidebar-menu.tsx'));
export const ModalContainer = lazy(() => import('soapbox/features/ui/containers/modal-container.ts'));
export const ProfileHoverCard = lazy(() => import('soapbox/components/profile-hover-card.tsx'));
@ -109,6 +109,7 @@ export const FederationRestrictions = lazy(() => import('soapbox/features/federa
export const Aliases = lazy(() => import('soapbox/features/aliases/index.tsx'));
export const Migration = lazy(() => import('soapbox/features/migration/index.tsx'));
export const WhoToFollowPanel = lazy(() => import('soapbox/features/ui/components/who-to-follow-panel.tsx'));
export const LatestAccountsPanel = lazy(() => import('soapbox/features/ui/components/latest-accounts-panel.tsx'));
export const FollowRecommendations = lazy(() => import('soapbox/features/follow-recommendations/index.tsx'));
export const Directory = lazy(() => import('soapbox/features/directory/index.tsx'));
export const RegisterInvite = lazy(() => import('soapbox/features/register-invite/index.tsx'));

Wyświetl plik

@ -990,6 +990,7 @@
"keyboard_shortcuts.up": "to move up in the list",
"landing_page_modal.download": "Download",
"landing_page_modal.help_center": "Help Center",
"latest_accounts.title": "Latest Accounts",
"lightbox.close": "Close",
"lightbox.expand": "Expand",
"lightbox.minimize": "Minimize",

Wyświetl plik

@ -1,7 +1,5 @@
import Layout from 'soapbox/components/ui/layout.tsx';
import {
LatestAccountsPanel,
} from 'soapbox/features/ui/util/async-components.ts';
import { LatestAdminAccountsPanel } from 'soapbox/features/ui/util/async-components.ts';
import LinkFooter from '../features/ui/components/link-footer.tsx';
@ -17,7 +15,7 @@ const AdminPage: React.FC<IAdminPage> = ({ children }) => {
</Layout.Main>
<Layout.Aside>
<LatestAccountsPanel limit={5} />
<LatestAdminAccountsPanel limit={5} />
<LinkFooter />
</Layout.Aside>
</>

Wyświetl plik

@ -20,6 +20,7 @@ import {
BirthdayPanel,
CtaBanner,
AnnouncementsPanel,
LatestAccountsPanel,
} from 'soapbox/features/ui/util/async-components.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
@ -62,6 +63,14 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
const acct = account ? account.acct : '';
const avatar = account ? account.avatar : '';
const renderSuggestions = () => {
if (features.suggestionsLocal && pathname !== '/timeline/global') {
return <LatestAccountsPanel limit={3} />;
} else if (features.suggestions) {
return <WhoToFollowPanel limit={3} />;
}
};
return (
<>
<Layout.Main className={clsx('space-y-0 dark:divide-gray-800')}>
@ -120,9 +129,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
{features.trends && (
<TrendsPanel limit={5} />
)}
{features.suggestions && (
<WhoToFollowPanel limit={3} />
)}
{renderSuggestions()}
{features.birthdays && (
<BirthdayPanel limit={10} />
)}

Wyświetl plik

@ -7,14 +7,15 @@ import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { PaginatedResult, removePageItem } from '../utils/queries.ts';
import type { IAccount } from './accounts.ts';
import type { Account } from 'soapbox/schemas/account.ts';
type Suggestion = {
source: 'staff';
account: IAccount;
source: string;
account: Account;
}
type Result = {
source: string;
account: string;
}
@ -24,47 +25,51 @@ type PageParam = {
const SuggestionKeys = {
suggestions: ['suggestions'] as const,
localSuggestions: ['suggestions', 'local'] as const,
};
const useSuggestions = () => {
interface UseSuggestionsOpts {
local?: boolean;
}
const useSuggestions = (opts?: UseSuggestionsOpts) => {
const api = useApi();
const dispatch = useAppDispatch();
const local = opts?.local ?? false;
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || '/api/v2/suggestions';
const getV2Suggestions = async (pageParam?: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || (local ? '/api/v2/ditto/suggestions/local' : '/api/v2/suggestions');
const response = await api.get(endpoint);
const next = response.next();
const hasMore = !!next;
const data: Suggestion[] = await response.json();
const accounts = data.map(({ account }) => account);
const accountIds = accounts.map((account) => account.id);
dispatch(importFetchedAccounts(accounts));
dispatch(fetchRelationships(accountIds));
return {
result: data.map(x => ({ ...x, account: x.account.id })),
link: next ?? undefined,
hasMore,
hasMore: !!next,
};
};
const result = useInfiniteQuery({
queryKey: SuggestionKeys.suggestions,
queryFn: ({ pageParam }: any) => getV2Suggestions(pageParam),
queryKey: local ? SuggestionKeys.localSuggestions : SuggestionKeys.suggestions,
queryFn: ({ pageParam }) => getV2Suggestions(pageParam),
placeholderData: keepPreviousData,
initialPageParam: { nextLink: undefined },
getNextPageParam: (config) => {
initialPageParam: undefined as PageParam | undefined,
getNextPageParam: (config): PageParam | undefined => {
if (config?.hasMore) {
return { nextLink: config?.link };
return { link: config?.link };
}
return undefined;
},
});
const data: any = result.data?.pages.reduce<Suggestion[]>(
(prev: any, curr: any) => [...prev, ...curr.result],
const data = result.data?.pages.reduce<Result[]>(
(prev, curr) => [...prev, ...curr.result],
[],
);
@ -133,4 +138,5 @@ function useOnboardingSuggestions() {
};
}
export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion };

Wyświetl plik

@ -1027,6 +1027,8 @@ const getInstanceFeatures = (instance: InstanceV1 | InstanceV2) => {
features.includes('v2_suggestions'),
]),
suggestionsLocal: v.software === DITTO,
/**
* Supports V2 suggested accounts.
* @see GET /api/v2/suggestions