refactor: migrate 5chan to the community hooks API (#1073)

* refactor(community-api): migrate 5chan to community hooks

* fix(review): address PR feedback

* fix(review): preserve legacy board context fallbacks

* fix(review): address latest bot feedback

* fix(review): use communityAddress in edit menu privileges
This commit is contained in:
Tommaso Casaburi
2026-03-13 13:25:10 +08:00
committed by GitHub
parent 9dc4d96d27
commit d7703953fb
105 changed files with 2659 additions and 1491 deletions
+19 -19
View File
@@ -13,7 +13,7 @@ type TestComment = {
content?: string;
title?: string;
pinned?: boolean;
subplebbitAddress?: string;
communityAddress?: string;
deleted?: boolean;
postCid?: string;
removed?: boolean;
@@ -54,13 +54,13 @@ const testState = vi.hoisted(() => ({
paginationFeedPostsPerPage: 6,
},
resetMock: vi.fn(),
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
searchText: '',
setCurrentSubplebbitAddressMock: vi.fn(),
setCurrentCommunityAddressMock: vi.fn(),
setMatchedFilterMock: vi.fn(),
setResetFunctionMock: vi.fn(),
sortType: 'new' as 'active' | 'new',
subplebbit: {
community: {
error: undefined as Error | undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
@@ -74,7 +74,7 @@ function getCatalogFiltersState() {
filterItems: testState.filterItems,
incrementFilterCount: testState.incrementFilterCountMock,
searchText: testState.searchText,
setCurrentSubplebbitAddress: testState.setCurrentSubplebbitAddressMock,
setCurrentSubplebbitAddress: testState.setCurrentCommunityAddressMock,
setMatchedFilter: testState.setMatchedFilterMock,
};
}
@@ -101,7 +101,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
}),
useSubplebbit: () => testState.subplebbit,
useCommunity: () => testState.community,
}));
vi.mock('react-virtuoso', () => ({
@@ -152,8 +152,8 @@ vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
vi.mock('../../../hooks/use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
}));
vi.mock('../../../hooks/use-state-string', () => ({
@@ -192,8 +192,8 @@ vi.mock('../../../components/catalog-row', () => ({
}));
vi.mock('../../../components/footer', () => ({
CatalogFooterFirstRow: ({ subplebbitAddress }: { subplebbitAddress?: string }) =>
createElement('div', { 'data-testid': 'catalog-first-row' }, subplebbitAddress || 'multi'),
CatalogFooterFirstRow: ({ communityAddress }: { communityAddress?: string }) =>
createElement('div', { 'data-testid': 'catalog-first-row' }, communityAddress || 'multi'),
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-mobile' }, children),
}));
@@ -287,10 +287,10 @@ describe('Catalog', () => {
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.resolvedSubplebbitAddress = 'music-posting.eth';
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.searchText = '';
testState.sortType = 'new';
testState.subplebbit = {
testState.community = {
error: undefined,
shortAddress: 'music-posting.eth',
state: 'ready',
@@ -300,7 +300,7 @@ describe('Catalog', () => {
testState.incrementFilterCountMock.mockReset();
testState.loadMoreMock.mockReset();
testState.resetMock.mockReset();
testState.setCurrentSubplebbitAddressMock.mockReset();
testState.setCurrentCommunityAddressMock.mockReset();
testState.setMatchedFilterMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
@@ -317,9 +317,9 @@ describe('Catalog', () => {
it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => {
testState.feed = [
{ cid: 'boring-post', title: 'plain talk', content: 'nothing special', subplebbitAddress: 'music-posting.eth' },
{ cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', subplebbitAddress: 'music-posting.eth' },
{ cid: 'top-post', title: 'cats forever', content: 'hello world', subplebbitAddress: 'music-posting.eth' },
{ cid: 'boring-post', title: 'plain talk', content: 'nothing special', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', communityAddress: 'music-posting.eth' },
{ cid: 'top-post', title: 'cats forever', content: 'hello world', communityAddress: 'music-posting.eth' },
];
testState.filterItems = [
{ count: 0, enabled: true, filteredCids: new Set(), hide: true, text: 'spoiler', top: false },
@@ -329,7 +329,7 @@ describe('Catalog', () => {
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(document.title).toBe('/mu/ - catalog - 5chan');
expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenCalledWith('music-posting.eth');
expect(testState.setCurrentCommunityAddressMock).toHaveBeenCalledWith('music-posting.eth');
expect(testState.clearMatchedFiltersMock).toHaveBeenCalled();
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post', 'row:boring-post']);
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth');
@@ -338,14 +338,14 @@ describe('Catalog', () => {
act(() => root.unmount());
expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenLastCalledWith(null);
expect(testState.setCurrentCommunityAddressMock).toHaveBeenLastCalledWith(null);
expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3);
root = createRoot(container);
});
it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', subplebbitAddress: 'music-posting.eth' }];
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
testState.hasMore = true;
await renderCatalog({
+51 -50
View File
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Comment, useAccount, useFeed, useSubplebbit, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useWindowWidth from '../../hooks/use-window-width';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
@@ -28,7 +28,7 @@ import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
interface CatalogFooterProps {
subplebbitAddresses: string[];
communityAddresses: string[];
hasMore: boolean;
combinedFeedLength: number;
isInAllView: boolean;
@@ -42,7 +42,7 @@ interface CatalogFooterProps {
// The useFeedStateString hook is called here instead of in Catalog to isolate re-renders
// caused by backend IPFS state changes to just this footer component
const CatalogFooter = ({
subplebbitAddresses,
communityAddresses,
hasMore,
combinedFeedLength,
isInAllView,
@@ -52,13 +52,13 @@ const CatalogFooter = ({
}: CatalogFooterProps) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(subplebbitAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
let footerContent;
if (combinedFeedLength === 0) {
footerContent = t('no_threads');
}
if (hasMore || (subplebbitAddresses && subplebbitAddresses.length === 0)) {
if (hasMore || (communityAddresses && communityAddresses.length === 0)) {
footerContent = (
<>
{showLoadingEllipsis && (
@@ -75,7 +75,7 @@ const CatalogFooter = ({
// Separate component for the loading state when there's no feed
// This also calls useFeedStateString internally to isolate re-renders
interface CatalogLoadingProps {
subplebbitAddresses: string[];
communityAddresses: string[];
hasMore: boolean;
combinedFeedLength: number;
state: string | undefined;
@@ -83,10 +83,10 @@ interface CatalogLoadingProps {
error: Error | undefined;
}
const CatalogLoading = ({ subplebbitAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => {
const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => {
const { t } = useTranslation();
const rawFeedStateString = useFeedStateString(subplebbitAddresses);
const rawFeedStateString = useFeedStateString(communityAddresses);
const loadingStateString = rawFeedStateString || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
return (
@@ -107,8 +107,8 @@ const CatalogLoading = ({ subplebbitAddresses, hasMore, combinedFeedLength, stat
const createContentFilter = (
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
subplebbitAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
) => {
// Create a unique key based on the enabled filter items
const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '');
@@ -133,10 +133,10 @@ const createContentFilter = (
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
if (filterIndex !== -1) {
if (onFilterMatch) {
onFilterMatch(filterIndex, comment.cid, subplebbitAddress);
onFilterMatch(filterIndex, comment.cid, communityAddress);
} else {
// Fallback to the store method if no callback provided
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, subplebbitAddress);
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, communityAddress);
}
// If the filter has a color, track it in the matchedFilters map
@@ -164,10 +164,10 @@ const createContentFilter = (
const createCombinedFilter = (
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
searchText: string,
subplebbitAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, subplebbitAddress: string) => void,
communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
) => {
const contentFilter = createContentFilter(filterItems, subplebbitAddress, onFilterMatch);
const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch);
const searchFilter = {
filter: (comment: Comment) => {
@@ -210,8 +210,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const effectiveInfiniteScroll = isMultiboard;
const directories = useDirectories();
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
const subplebbitAddress = useMemo(() => {
const resolvedAddressFromUrl = useResolvedCommunityAddress();
const communityAddress = useMemo(() => {
if (boardIdentifierProp) {
return getSubplebbitAddress(boardIdentifierProp, directories);
}
@@ -224,16 +224,16 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const subscriptions = account?.subscriptions;
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
const subplebbitAddresses = useMemo(() => {
const communityAddresses = useMemo(() => {
if (isInAllView) {
return filteredDirectoryAddresses;
}
if (isInSubscriptionsView) {
return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values
}
// Only include subplebbitAddress if it's defined
return subplebbitAddress ? [subplebbitAddress] : [];
}, [isInAllView, isInSubscriptionsView, subplebbitAddress, filteredDirectoryAddresses, subscriptions]);
// Only include communityAddress if it's defined
return communityAddress ? [communityAddress] : [];
}, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]);
const { imageSize } = useCatalogStyleStore();
const columnWidth = imageSize === 'Large' ? 270 : 180;
@@ -241,8 +241,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const columnCount = Math.floor(useWindowWidth() / columnWidth);
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress);
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(community);
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
// Canonical redirect for multiboard catalog paths with numeric page segment (e.g. /all/catalog/1w/5 -> /all/catalog/1w)
useEffect(() => {
@@ -257,26 +257,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const feedSortType = sortType === 'new' ? 'new' : 'active';
// Create a stable callback for filter matching
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, subplebbitAddress);
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, cid, communityAddress);
}, []);
// Set the current subplebbit address
// Set the current community address
useEffect(() => {
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(subplebbitAddress || null);
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(communityAddress || null);
return () => {
useCatalogFiltersStore.getState().setCurrentSubplebbitAddress(null);
};
}, [subplebbitAddress]);
}, [communityAddress]);
const feedOptions = useMemo(() => {
return {
subplebbitAddresses,
communityAddresses,
sortType: feedSortType,
postsPerPage: isMultiboard ? 10 : paginationFeedPostsPerPage,
filter: createCombinedFilter(filterItems, searchText, subplebbitAddress || 'all', handleFilterMatch),
filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch),
};
}, [subplebbitAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, subplebbitAddress, handleFilterMatch]);
}, [communityAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const { accountComments } = useAccountComments();
@@ -289,6 +289,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() =>
accountComments.filter((comment) => {
const { cid, deleted, postCid, removed, state, timestamp } = comment || {};
const commentCommunityAddress = comment?.communityAddress || comment?.subplebbitAddress;
// Basic filtering conditions
const basicConditions =
@@ -298,7 +299,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
state === 'succeeded' &&
cid &&
cid === postCid &&
comment?.subplebbitAddress === subplebbitAddress &&
commentCommunityAddress === communityAddress &&
!feedCids.has(cid);
// If search is active, also check search conditions
@@ -312,7 +313,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return basicConditions;
}),
[accountComments, subplebbitAddress, feedCids, searchText],
[accountComments, communityAddress, feedCids, searchText],
);
// show newest account comment at the top of the feed but after pinned posts
@@ -347,8 +348,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
}
}, [reset, setResetFunction, isVisible]);
const subplebbit = useSubplebbit({ subplebbitAddress });
const { error, shortAddress, state, title } = subplebbit || {};
const community = useCommunity({ communityAddress });
const { error, shortAddress, state, title } = community || {};
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
@@ -357,7 +358,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
Footer: () => (
<>
<CatalogFooter
subplebbitAddresses={subplebbitAddresses}
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
isInAllView={isInAllView}
@@ -368,7 +369,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
subplebbitAddress={subplebbitAddress}
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
@@ -377,8 +378,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
/>
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={subplebbitAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={subplebbitAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<TopButton />
<RefreshButton />
</div>
@@ -386,7 +387,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</>
),
}),
[subplebbitAddresses, hasMore, cappedFeed.length, subplebbitAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
@@ -424,7 +425,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return [...topPosts, ...regularPosts];
}, [sortedFeed, filterItems]);
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, community);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
@@ -469,18 +470,18 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
} else if (isDirectory) {
documentTitle = `/${boardIdentifier}/`;
} else {
documentTitle = title ? title : shortAddress || subplebbitAddress || '';
documentTitle = title ? title : shortAddress || communityAddress || '';
}
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
}, [title, shortAddress, subplebbitAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]);
}, [title, shortAddress, communityAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]);
// Clear matched filters when component mounts or when subplebbit changes
// Clear matched filters when component mounts or when community changes
useEffect(() => {
clearMatchedFilters();
return () => {
clearMatchedFilters();
};
}, [clearMatchedFilters, subplebbitAddress]);
}, [clearMatchedFilters, communityAddress]);
// Memoize filter color application to avoid redundant iterations
useMemo(() => {
@@ -529,7 +530,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<>
<div className={styles.footer}>
<CatalogLoading
subplebbitAddresses={subplebbitAddresses}
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
state={state}
@@ -540,7 +541,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
subplebbitAddress={subplebbitAddress}
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
@@ -549,8 +550,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
/>
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={subplebbitAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={subplebbitAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<TopButton />
<RefreshButton />
</div>