merge: empty feed completion

This commit is contained in:
Tommaso Casaburi
2026-07-11 15:05:36 +07:00
7 changed files with 163 additions and 46 deletions

View File

@@ -0,0 +1,5 @@
.message {
color: var(--red);
font-size: 13px;
text-transform: lowercase;
}

View File

@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next';
import styles from './empty-feed-message.module.css';
const EmptyFeedMessage = () => {
const { t } = useTranslation();
return <div className={styles.message}>{t('nothing_found')}</div>;
};
export default EmptyFeedMessage;

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { shouldShowEmptyFeed } from './feed-footer-utils';
describe('shouldShowEmptyFeed', () => {
it.each([0, 1, 60, 1000])('shows the empty state after all %i requested communities load even when pagination has more', (requestedCommunityCount) => {
const communities = Array.from({ length: requestedCommunityCount }, (_, index) => ({ updatedAt: index + 1 }));
const emptyLoadedFeed = {
requestedCommunityCount,
communities,
feedLength: 0,
hasMore: true,
};
expect(shouldShowEmptyFeed(emptyLoadedFeed)).toBe(true);
});
it('keeps loading while any requested community has not loaded', () => {
expect(
shouldShowEmptyFeed({
requestedCommunityCount: 2,
communities: [{ updatedAt: 1 }, undefined],
feedLength: 0,
}),
).toBe(false);
expect(
shouldShowEmptyFeed({
requestedCommunityCount: 2,
communities: [{ updatedAt: 1 }, {}],
feedLength: 0,
}),
).toBe(false);
});
it('does not show the empty state when posts or a search are present', () => {
const loadedFeed = {
requestedCommunityCount: 1,
communities: [{ updatedAt: 1 }],
};
expect(shouldShowEmptyFeed({ ...loadedFeed, feedLength: 1 })).toBe(false);
expect(shouldShowEmptyFeed({ ...loadedFeed, feedLength: 0, isLoadingCommunityData: true })).toBe(false);
expect(shouldShowEmptyFeed({ ...loadedFeed, feedLength: 0, isSearching: true })).toBe(false);
expect(shouldShowEmptyFeed({ ...loadedFeed, feedLength: 0, searchQuery: 'query' })).toBe(false);
});
});

View File

@@ -0,0 +1,27 @@
interface FeedCommunityLoadState {
updatedAt?: number;
}
interface ShouldShowEmptyFeedOptions {
requestedCommunityCount: number;
communities: Array<FeedCommunityLoadState | undefined>;
feedLength: number;
isLoadingCommunityData?: boolean;
isSearching?: boolean;
searchQuery?: string;
}
export const shouldShowEmptyFeed = ({
requestedCommunityCount,
communities,
feedLength,
isLoadingCommunityData,
isSearching,
searchQuery,
}: ShouldShowEmptyFeedOptions): boolean => {
// `hasMore` also represents pagination. `updatedAt` is the hook's count-safe
// evidence that each requested community snapshot loaded.
const haveAllRequestedCommunitiesLoaded = communities.length === requestedCommunityCount && communities.every((community) => Boolean(community?.updatedAt));
return haveAllRequestedCommunitiesLoaded && feedLength === 0 && !isLoadingCommunityData && !isSearching && !searchQuery;
};

View File

@@ -48,4 +48,4 @@
color: var(--red);
font-size: 13px;
text-transform: lowercase;
}
}

View File

@@ -1,9 +1,13 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useCommunities } from '@bitsocial/bitsocial-react-hooks';
import { isAllView, isModView } from '../../lib/utils/view-utils';
import { useCommunityIdentifiers } from '../../hooks/use-community-identifier';
import { useFeedStateString } from '../../hooks/use-state-string';
import EmptyFeedMessage from '../empty-feed-message/empty-feed-message';
import LoadingEllipsis from '../loading-ellipsis';
import { shouldShowEmptyFeed } from './feed-footer-utils';
import styles from './feed-footer.module.css';
import React from 'react';
@@ -45,12 +49,55 @@ const FeedFooter = ({
const isInModView = isModView(location.pathname);
const isInAllView = isAllView(location.pathname);
const feedCommunityIdentifiers = useCommunityIdentifiers(communityAddresses);
const { communities } = useCommunities({ communities: feedCommunityIdentifiers });
const feedStateString = useFeedStateString(communityAddresses);
const loadingStateString =
useFeedStateString(communityAddresses) ||
feedStateString ||
(!hasFeedLoaded || (feedLength === 0 && !(weeklyFeedLength > feedLength || monthlyFeedLength > feedLength || yearlyFeedLength > feedLength))
? t('loading_feed')
: t('looking_for_more_posts'));
const hasEmptyFeedData = shouldShowEmptyFeed({
requestedCommunityCount: feedCommunityIdentifiers.length,
communities,
feedLength,
isLoadingCommunityData: Boolean(feedStateString),
isSearching,
searchQuery,
});
const widerFeedSuggestion =
weeklyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_week'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='weekly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1w'} />,
}}
/>
</div>
) : monthlyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_month'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='monthly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1m'} />,
}}
/>
</div>
) : yearlyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_year'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='yearly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1y'} />,
}}
/>
</div>
) : null;
// Add state to track initial loading
const [hasFetchedCommunityAddresses, setHasFetchedSubplebbitAddresses] = useState(false);
@@ -63,6 +110,8 @@ const FeedFooter = ({
return () => clearTimeout(timer);
}, []);
const showEmptyFeed = hasFetchedCommunityAddresses && hasEmptyFeedData;
if (!hasFetchedCommunityAddresses) {
footerContent = <LoadingEllipsis string={t('loading_feed')} />;
}
@@ -101,57 +150,32 @@ const FeedFooter = ({
</div>
</div>
);
} else if (
hasFeedLoaded &&
feedLength === 0 &&
!hasMore &&
!isSearching &&
!searchQuery &&
!(weeklyFeedLength > feedLength || monthlyFeedLength > feedLength || yearlyFeedLength > feedLength)
) {
footerContent = t('no_posts');
} else if (showEmptyFeed) {
footerContent = (
<>
{widerFeedSuggestion}
<EmptyFeedMessage />
</>
);
} else if (hasMore || communityAddresses.length > 0 || (communityAddresses && communityAddresses.length === 0)) {
// Only show newer posts/weekly/monthly suggestions when not searching
footerContent = (
<>
{weeklyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_week'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='weekly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1w'} />,
}}
/>
</div>
) : monthlyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_month'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='monthly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1m'} />,
}}
/>
</div>
) : yearlyFeedLength > feedLength && !searchQuery ? (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey='more_posts_last_year'
values={{ currentTimeFilterName, count: feedLength }}
components={{
1: <Link key='yearly-posts-link' to={(isInModView ? '/s/mod/' : isInAllView ? '/s/all/' : '/') + (params?.sortType || 'hot') + '/1y'} />,
}}
/>
</div>
) : null}
{widerFeedSuggestion}
<div className={styles.stateString}>
{communityAddresses.length === 0 ? (
isInModView ? (
<div className={styles.notModerator}>{t('not_moderator')}</div>
) : (
<div>
<Trans i18nKey='no_communities_found' components={[<a href='https://github.com/bitsocialhq/lists'>https://github.com/bitsocialhq/lists</a>]} />
<Trans
i18nKey='no_communities_found'
components={[
<a key='community-lists-link' href='https://github.com/bitsocialhq/lists'>
https://github.com/bitsocialhq/lists
</a>,
]}
/>
<br />
{t('connect_community_notice')}
</div>

View File

@@ -18,6 +18,7 @@ import { isResolvableCommunityAddress } from '../../lib/utils/directory-codes';
import useTimeFilter, { isValidTimeFilterName } from '../../hooks/use-time-filter';
import { getCommunityIdentifier, getCommunityIdentifiers } from '../../hooks/use-community-identifier';
import ErrorDisplay from '../../components/error-display';
import EmptyFeedMessage from '../../components/empty-feed-message/empty-feed-message';
import LoadingEllipsis from '../../components/loading-ellipsis';
import Over18Warning from '../../components/over-18-warning';
import Post from '../../components/post';
@@ -31,6 +32,7 @@ interface FooterProps {
communityAddress: string;
feedLength: number;
isOnline: boolean;
hasCommunityLoaded: boolean;
started: boolean;
isSubCreatedButNotYetPublished: boolean;
hasMore: boolean;
@@ -46,6 +48,7 @@ const Footer = ({
communityAddress,
feedLength,
isOnline,
hasCommunityLoaded,
started: _started,
isSubCreatedButNotYetPublished: _isSubCreatedButNotYetPublished,
hasMore,
@@ -58,7 +61,8 @@ const Footer = ({
const { t } = useTranslation();
let footerFirstLine;
let footerSecondLine;
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
const feedStateString = useFeedStateString(communityAddresses);
const loadingStateString = feedStateString || t('loading');
const [showNoResults, setShowNoResults] = useState(false);
const [searchAttemptCompleted, setSearchAttemptCompleted] = useState(false);
@@ -180,8 +184,8 @@ const Footer = ({
</div>
);
}
} else if (feedLength === 0 && isOnline && !hasMore) {
footerFirstLine = t('no_posts');
} else if (feedLength === 0 && isOnline && hasCommunityLoaded && !feedStateString) {
footerFirstLine = <EmptyFeedMessage />;
} else if (feedLength === 0 || !isOnline) {
footerFirstLine = loadingString;
} else if (hasMore) {
@@ -320,6 +324,7 @@ const CommunityView = () => {
communityAddress,
feedLength: combinedFeed.length || 0,
isOnline,
hasCommunityLoaded: Boolean(updatedAt),
started,
isSubCreatedButNotYetPublished,
hasMore,