@selfcommunity/react-ui
Version:
React UI Components to integrate a Community created with SelfCommunity Platform.
423 lines (416 loc) • 22.4 kB
JavaScript
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
// @ts-nocheck
import React, { forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Link, SCCache, SCPreferences, SCPreferencesContext, SCUserContext, useIsComponentMountedRef, usePreviousValue, useSCFetchFeed } from '@selfcommunity/react-core';
import { styled, useTheme } from '@mui/material/styles';
import { Box, Button, CardContent, Grid, Hidden, Typography, useMediaQuery } from '@mui/material';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { GenericSkeleton } from '../Skeleton';
import CustomAdv from '../CustomAdv';
import { SCCustomAdvPosition } from '@selfcommunity/types';
import { CacheStrategies, getQueryStringParameter, isClientSideRendering, updateQueryStringParameter } from '@selfcommunity/utils';
import classNames from 'classnames';
import PubSub from 'pubsub-js';
import { useThemeProps } from '@mui/system';
import Widget from '../Widget';
import InfiniteScroll from '../../shared/InfiniteScroll';
import VirtualizedScroller, { VirtualScrollChild } from '../../shared/VirtualizedScroller';
import { DEFAULT_WIDGETS_NUMBER, WIDGET_PREFIX_KEY } from '../../constants/Feed';
import { DEFAULT_PAGINATION_LIMIT, DEFAULT_PAGINATION_OFFSET, DEFAULT_PAGINATION_QUERY_PARAM_NAME } from '../../constants/Pagination';
import { widgetSort } from '../../utils/feed';
import Footer from '../Footer';
import FeedSkeleton from './Skeleton';
import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect';
import StickyBoxComp from '../../shared/StickyBox';
import { PREFIX } from './constants';
const messages = defineMessages({
refresh: {
id: 'ui.feed.refreshRelease',
defaultMessage: 'ui.feed.refreshRelease'
}
});
const classes = {
root: `${PREFIX}-root`,
left: `${PREFIX}-left`,
leftItems: `${PREFIX}-left-items`,
start: `${PREFIX}-start`,
headerItem: `${PREFIX}-header-item`,
end: `${PREFIX}-end`,
endMessage: `${PREFIX}-end-message`,
right: `${PREFIX}-right`,
refresh: `${PREFIX}-refresh`,
paginationLink: `${PREFIX}-pagination-link`
};
const Root = styled(Grid, {
name: PREFIX,
slot: 'Root'
})(() => ({}));
const PREFERENCES = [SCPreferences.ADVERTISING_CUSTOM_ADV_ENABLED, SCPreferences.ADVERTISING_CUSTOM_ADV_ONLY_FOR_ANONYMOUS_USERS_ENABLED];
/**
* > API documentation for the Community-JS Feed component. Learn about the available props and the CSS API.
*
*
* This component renders a feed.
* Take a look at our <strong>demo</strong> component [here](/docs/sdk/community-js/react-ui/Components/Feed)
#### Import
```jsx
import {Feed} from '@selfcommunity/react-ui';
```
#### Component Name
The name `SCFeed` can be used when providing style overrides in the theme.
#### CSS
|Rule Name|Global class|Description|
|---|---|---|
|root|.SCFeed-root|Styles applied to the root element.|
|left|.SCFeed-left|Styles applied to the left element.|
|right|.SCFeed-right|Styles applied to the right element.|
|end|.SCFeed-end|Styles applied to the end element.|
|refresh|.SCFeed-refresh|Styles applied to the refresh section.|
|paginationLink|.SCFeed-pagination-link|Styles applied to pagination links.|
*
* @param inProps
*/
const Feed = (inProps, ref) => {
// PROPS
const props = useThemeProps({
props: inProps,
name: PREFIX
});
// HOOKS
const intl = useIntl();
const { id = 'feed', className, endpoint, endpointQueryParams = { limit: DEFAULT_PAGINATION_LIMIT, offset: DEFAULT_PAGINATION_OFFSET }, endMessage = _jsx(FormattedMessage, { id: "ui.feed.noOtherFeedObject", defaultMessage: "ui.feed.noOtherFeedObject" }), refreshMessage = _jsx(Typography, { dangerouslySetInnerHTML: { __html: `${intl.formatMessage(messages.refresh)}` } }), HeaderComponent, FooterComponent = Footer, FooterComponentProps = {}, widgets = [], ItemComponent, itemPropsGenerator, itemIdGenerator, ItemProps = {}, ItemSkeleton, ItemSkeletonProps = {}, onNextData, onPreviousData, FeedSidebarProps = {}, CustomAdvProps = {}, enabledCustomAdvPositions = [SCCustomAdvPosition.POSITION_FEED_SIDEBAR, SCCustomAdvPosition.POSITION_FEED], requireAuthentication = false, cacheStrategy = CacheStrategies.NETWORK_ONLY, prefetchedData, scrollableTargetId, VirtualizedScrollerProps = {}, disablePaginationLinks = false, hidePaginationLinks = true, paginationLinksPageQueryParam = DEFAULT_PAGINATION_QUERY_PARAM_NAME, PaginationLinkProps = {}, hideAdvs = false, emptyFeedPlaceholder } = props;
// CONTEXT
const scPreferences = useContext(SCPreferencesContext);
const scUserContext = useContext(SCUserContext);
// CONST
const authUserId = scUserContext.user ? scUserContext.user.id : null;
const limit = useMemo(() => endpointQueryParams.limit || DEFAULT_PAGINATION_LIMIT, [endpointQueryParams]);
const offset = useMemo(() => {
if (prefetchedData) {
const currentOffset = getQueryStringParameter(prefetchedData.previous, 'offset') || 0;
return prefetchedData.previous ? parseInt(currentOffset) + limit : 0;
}
return endpointQueryParams.offset || 0;
}, [endpointQueryParams, prefetchedData]);
// REF
const isMountRef = useIsComponentMountedRef();
const containerRef = useRef(null);
/**
* Compute preferences
*/
const preferences = useMemo(() => {
const _preferences = {};
PREFERENCES.map((p) => (_preferences[p] = scPreferences.preferences && p in scPreferences.preferences ? scPreferences.preferences[p].value : null));
return _preferences;
}, [scPreferences.preferences]);
// RENDER
const theme = useTheme();
const oneColLayout = useMediaQuery(theme.breakpoints.down('md'), { noSsr: isClientSideRendering() });
const advEnabled = useMemo(() => preferences &&
preferences[SCPreferences.ADVERTISING_CUSTOM_ADV_ENABLED] &&
((preferences[SCPreferences.ADVERTISING_CUSTOM_ADV_ONLY_FOR_ANONYMOUS_USERS_ENABLED] && scUserContext.user === null) ||
!preferences[SCPreferences.ADVERTISING_CUSTOM_ADV_ONLY_FOR_ANONYMOUS_USERS_ENABLED]), [preferences]);
const prevWidgets = usePreviousValue(widgets);
/**
* Callback onNextPage
* @param page
* @param offset
* @param total
* @param data
*/
const onNextPage = (page, offset, total, data) => {
setFeedDataLeft((prev) => prev.concat(_getFeedDataLeft(data, offset, total)));
onNextData && onNextData(page, offset, total, data);
};
/**
* Callback onPreviousPage
* @param page
* @param offset
* @param total
* @param data
*/
const onPreviousPage = (page, offset, total, data) => {
setFeedDataLeft((prev) => _getFeedDataLeft(data, offset, total).concat(prev));
// Remove item duplicated from headData if the page data already contains
removeHeadDuplicatedData(data.map((item) => itemIdGenerator(item)));
onPreviousData && onPreviousData(page, offset, total, data);
};
// PAGINATION FEED
const feedDataObject = useSCFetchFeed({
id,
endpoint,
endpointQueryParams: Object.assign(Object.assign({}, endpointQueryParams), { offset, limit }),
onNextPage: onNextPage,
onPreviousPage: onPreviousPage,
cacheStrategy,
prefetchedData
});
/**
* Compute Base Widgets
*/
const _widgets = useMemo(() => [
...widgets,
...(advEnabled && enabledCustomAdvPositions.includes(SCCustomAdvPosition.POSITION_FEED_SIDEBAR)
? [
{
type: 'widget',
component: CustomAdv,
componentProps: Object.assign({ position: SCCustomAdvPosition.POSITION_FEED_SIDEBAR }, CustomAdvProps),
column: 'right',
position: 0
}
]
: [])
]
.map((w, i) => Object.assign({}, w, { position: w.position * (w.column === 'right' ? 5 : 1), id: `${w.column}_${i}` }))
.sort(widgetSort), [widgets, advEnabled]);
/**
* Compute Widgets for the left column in a specific position
*/
const _getLeftColumnWidgets = (position = 1, total) => {
const tw = {
type: 'widget',
component: CustomAdv,
componentProps: Object.assign({ position: SCCustomAdvPosition.POSITION_FEED }, CustomAdvProps),
column: 'left',
position,
id: `left_${position}`
};
if (oneColLayout && !hideAdvs) {
const remainingWidgets = position === total - 1 ? _widgets.filter((w) => w.position >= total) : [];
return [
..._widgets.filter((w) => w.position === position),
...(advEnabled &&
enabledCustomAdvPositions.includes(SCCustomAdvPosition.POSITION_FEED) &&
position > 0 &&
position % DEFAULT_WIDGETS_NUMBER === 0
? [tw]
: []),
...remainingWidgets
];
}
const remainingWidgets = position === total - 1 ? _widgets.filter((w) => w.position >= total && w.column === 'left') : [];
return [
..._widgets.filter((w) => w.position === position && w.column === 'left'),
...(advEnabled &&
!hideAdvs &&
enabledCustomAdvPositions.includes(SCCustomAdvPosition.POSITION_FEED) &&
position > 0 &&
position % DEFAULT_WIDGETS_NUMBER === 0
? [tw]
: []),
...remainingWidgets
];
};
/**
* Compute Widgets for the right column
*/
const _getRightColumnWidgets = () => {
if (oneColLayout) {
return [];
}
return _widgets.filter((w) => w.column === 'right');
};
/**
* Get left column data
* @param data
*/
const _getFeedDataLeft = (data, currentOffset, total) => {
let result = [];
if (total === 0) {
result = oneColLayout ? _widgets : _widgets.filter((w) => w.column === 'left');
}
else {
data.forEach((e, i) => {
result = result.concat([..._getLeftColumnWidgets(i + currentOffset, total), ...[e]]);
});
}
return result;
};
/**
* Get right column data
*/
const _getFeedDataRight = () => {
return _getRightColumnWidgets();
};
// STATE
const [feedDataLeft, setFeedDataLeft] = useState(prefetchedData ? _getFeedDataLeft(feedDataObject.results, feedDataObject.initialOffset, feedDataObject.count) : []);
const [feedDataRight, setFeedDataRight] = useState(prefetchedData ? _getFeedDataRight() : []);
const [headData, setHeadData] = useState([]);
// REFS
const refreshSubscription = useRef(null);
const virtualScrollerState = useRef(null);
const virtualScrollerMountState = useRef(false);
// VIRTUAL SCROLL HELPERS
const getScrollItemId = useMemo(() => (item) => item.type === 'widget' ? `${WIDGET_PREFIX_KEY}${item.id}` : `${item.type}_${itemIdGenerator(item)}`, []);
/**
* Callback on scroll mount
*/
const onScrollerMount = useMemo(() => () => {
virtualScrollerMountState.current = true;
}, []);
/**
* Callback on scroll mount
*/
const onScrollerStateChange = useMemo(() => (state) => {
virtualScrollerState.current = state;
}, []);
/**
* Callback on refresh
*/
const refresh = useMemo(() => () => {
/**
* Only if the feedDataObject is loaded reload data
*/
if (feedDataObject.componentLoaded) {
setHeadData([]);
setFeedDataLeft([]);
setFeedDataRight(_getFeedDataRight());
feedDataObject.reload();
}
}, [feedDataObject.componentLoaded, setHeadData, setFeedDataLeft, setFeedDataRight]);
/**
* Callback subscribe events
* @param msg
* @param data
*/
const subscriber = (msg, data) => {
if (data.refresh) {
refresh();
}
};
/**
* Render HeaderComponent
*/
const renderHeaderComponent = () => {
return (_jsx(Box, Object.assign({ className: classes.start }, { children: !feedDataObject.previous && (_jsxs(_Fragment, { children: [virtualScrollerMountState.current && HeaderComponent, headData.map((item) => {
const _itemId = `item_${itemIdGenerator(item)}`;
return (_jsx(ItemComponent, Object.assign({ className: classes.headerItem, id: _itemId }, itemPropsGenerator(scUserContext.user, item), ItemProps, { sx: { width: '100%' } }), _itemId));
})] })) })));
};
/**
* Infinite scroll getNextPage
*/
const getNextPage = useMemo(() => () => {
if (isMountRef.current && feedDataObject.componentLoaded && !feedDataObject.isLoadingNext) {
feedDataObject.getNextPage();
}
}, [isMountRef.current, feedDataObject.componentLoaded, feedDataObject.isLoadingNext]);
/**
* Infinite scroll getNextPage
*/
const getPreviousPage = useMemo(() => () => {
if (isMountRef.current && feedDataObject.componentLoaded && !feedDataObject.isLoadingPrevious) {
feedDataObject.getPreviousPage();
}
}, [isMountRef.current, feedDataObject.componentLoaded, feedDataObject.isLoadingPrevious]);
/**
* Bootstrap initial data
*/
const _initFeedData = useMemo(() => () => {
if (cacheStrategy === CacheStrategies.CACHE_FIRST && feedDataObject.componentLoaded) {
// Set current cached feed or prefetched data
setFeedDataLeft(_getFeedDataLeft(feedDataObject.results, feedDataObject.initialOffset, feedDataObject.count));
setFeedDataRight(_getFeedDataRight());
}
else if (!feedDataObject.componentLoaded) {
// Load next page
feedDataObject.getNextPage();
setFeedDataRight(_getFeedDataRight());
}
}, [cacheStrategy, feedDataObject.componentLoaded, endpointQueryParams]);
// EFFECTS
useEffect(() => {
/**
* Initialize feed
* Init feed data when the user is authenticated/un-authenticated
* Use setTimeout helper to delay the request and cancel the effect
* (ex. in strict-mode) if need it
*/
let _t;
if ((requireAuthentication && authUserId !== null && !prefetchedData) || (!requireAuthentication && !prefetchedData)) {
_t = setTimeout(_initFeedData);
}
return () => {
_t && clearTimeout(_t);
};
}, [requireAuthentication, authUserId, prefetchedData]);
/**
* If widgets changed, refresh the feed (it must recalculate the correct positions of the objects)
*/
useDeepCompareEffectNoCheck(() => {
if (prevWidgets && widgets && prevWidgets !== widgets) {
refresh();
}
}, [widgets]);
/**
* Subscribe/Unsubscribe for external events
*/
useEffect(() => {
refreshSubscription.current = PubSub.subscribe(id, subscriber);
return () => {
PubSub.unsubscribe(refreshSubscription.current);
};
}, [subscriber]);
/**
* Remove duplicated data when load previous page and
* previously some elements have been added in the head
*/
const removeHeadDuplicatedData = (itemIds) => {
setHeadData(headData.filter((item) => !itemIds.includes(itemIdGenerator(item))));
};
/**
* Next page url
* Useful for SSR and SEO
*/
const NextPageLink = useMemo(() => {
return (_jsx(_Fragment, { children: !disablePaginationLinks && feedDataObject.nextPage && (_jsx(Link, Object.assign({ to: `?${paginationLinksPageQueryParam}=${feedDataObject.nextPage}`, className: classNames({ [classes.paginationLink]: hidePaginationLinks }) }, PaginationLinkProps, { children: _jsx(FormattedMessage, { id: "ui.common.nextPage", defaultMessage: "ui.common.nextPage" }) }))) }));
}, [feedDataObject.nextPage, disablePaginationLinks, paginationLinksPageQueryParam, hidePaginationLinks]);
/**
* Previous page url
* Useful for SSR and SEO
*/
const PreviousPageLink = useMemo(() => {
return (_jsx(_Fragment, { children: !disablePaginationLinks && feedDataObject.previousPage && (_jsx(Link, Object.assign({ to: `?${paginationLinksPageQueryParam}=${feedDataObject.previousPage}`, className: classNames({ [classes.paginationLink]: hidePaginationLinks }) }, PaginationLinkProps, { children: _jsx(FormattedMessage, { id: "ui.common.previousPage", defaultMessage: "ui.common.previousPage" }) }))) }));
}, [feedDataObject.previousPage, disablePaginationLinks, paginationLinksPageQueryParam, hidePaginationLinks]);
// EXPOSED METHODS
useImperativeHandle(ref, () => ({
addFeedData: (data, syncPagination) => {
// Use headData to save new items in the head of the feed list
// In this way, the state of the feed (virtualScroller/cache) remains consistent
setHeadData([...[data], ...headData]);
if (syncPagination) {
// Adding an element, re-sync next and previous of feedDataObject
const nextOffset = parseInt(getQueryStringParameter(feedDataObject.next, 'offset') || feedDataObject.results.length - 1) + 1;
const previousOffset = parseInt(getQueryStringParameter(feedDataObject.previous, 'offset') || offset) + 1;
feedDataObject.updateState({
previous: updateQueryStringParameter(feedDataObject.previous, 'offset', previousOffset),
next: updateQueryStringParameter(feedDataObject.next, 'offset', nextOffset),
count: feedDataObject.count + 1
});
}
},
refresh: () => {
refresh();
},
getCurrentFeedObjectIds: () => {
return [...headData.map((o) => o[o.type].id), ...feedDataObject.results.map((o) => o[o.type].id)];
}
}));
const InnerItem = useMemo(() => ({ state: savedState, onHeightChange, onStateChange, children: item }) => {
const onItemHeightChange = () => {
if (savedState && savedState.firstShownItemIndex !== undefined) {
onHeightChange();
}
};
const onItemStateChange = (state) => {
onStateChange(Object.assign(Object.assign({}, savedState), state));
};
return (_jsx(VirtualScrollChild, Object.assign({ onHeightChange: onItemHeightChange }, { children: item.type === 'widget' ? (_jsx(item.component, Object.assign({ id: `${WIDGET_PREFIX_KEY}${item.position}` }, item.componentProps, (item.publishEvents && { publicationChannel: id }), savedState, { onStateChange: onItemStateChange, onHeightChange: onItemHeightChange }))) : (_jsx(ItemComponent, Object.assign({ id: `item_${itemIdGenerator(item)}` }, itemPropsGenerator(scUserContext.user, item), ItemProps, { sx: { width: '100%' } }, savedState, { onStateChange: onItemStateChange, onHeightChange: onItemHeightChange }))) })));
}, []);
if (feedDataObject.isLoadingNext && !feedDataLeft.length) {
return (_jsx(FeedSkeleton, { children: [...Array(3)].map((v, i) => (_jsx(ItemSkeleton, Object.assign({}, ItemSkeletonProps), i))) }));
}
return (_jsxs(Root, Object.assign({ container: true, spacing: 2, id: id, className: classNames(classes.root, className) }, { children: [advEnabled && !hideAdvs && enabledCustomAdvPositions.includes(SCCustomAdvPosition.POSITION_BELOW_TOPBAR) ? (_jsx(Grid, Object.assign({ item: true, xs: 12 }, { children: _jsx(CustomAdv, Object.assign({ position: SCCustomAdvPosition.POSITION_BELOW_TOPBAR }, CustomAdvProps)) }))) : null, _jsx(Grid, Object.assign({ item: true, xs: 12, md: 7 }, { children: _jsxs(InfiniteScroll, Object.assign({ ref: containerRef, className: classes.left, dataLength: feedDataLeft.length, next: getNextPage, previous: getPreviousPage, hasMoreNext: Boolean(feedDataObject.next), hasMorePrevious: Boolean(feedDataObject.previous), header: PreviousPageLink, footer: NextPageLink, loaderNext: _jsx(ItemSkeleton, Object.assign({}, ItemSkeletonProps)), loaderPrevious: _jsx(ItemSkeleton, Object.assign({}, ItemSkeletonProps)), scrollThreshold: '90%', endMessage: _jsxs(Box, Object.assign({ className: classes.end }, { children: [_jsx(Widget, Object.assign({ className: classes.endMessage }, { children: _jsx(CardContent, { children: endMessage }) })), FooterComponent ? _jsx(FooterComponent, Object.assign({}, FooterComponentProps)) : null] })), refreshFunction: refresh, pullDownToRefresh: true, pullDownToRefreshThreshold: 1000, pullDownToRefreshContent: null, releaseToRefreshContent: _jsx(Button, Object.assign({ color: "secondary", variant: "contained", className: classes.refresh }, { children: refreshMessage })), style: { overflow: 'visible' } }, (scrollableTargetId && { scrollableTarget: scrollableTargetId }), { children: [renderHeaderComponent(), feedDataObject.count === 0 && emptyFeedPlaceholder && emptyFeedPlaceholder, _jsx(VirtualizedScroller, Object.assign({ className: classes.leftItems, items: feedDataLeft, itemComponent: InnerItem, onMount: onScrollerMount, onScrollerStateChange: onScrollerStateChange, getItemId: getScrollItemId, preserveScrollPosition: true, preserveScrollPositionOnPrependItems: true, cacheScrollStateKey: SCCache.getVirtualizedScrollStateCacheKey(id), cacheScrollerPositionKey: SCCache.getFeedSPCacheKey(id), cacheStrategy: cacheStrategy }, (scrollableTargetId && { getScrollableContainer: () => document.getElementById(scrollableTargetId) }), VirtualizedScrollerProps))] })) })), feedDataRight.length > 0 && !hideAdvs && (_jsx(Hidden, Object.assign({ smDown: true }, { children: _jsx(Grid, Object.assign({ item: true, xs: 12, md: 5 }, { children: _jsx(StickyBoxComp, Object.assign({ className: classes.right }, FeedSidebarProps, { children: _jsx(React.Suspense, Object.assign({ fallback: _jsx(GenericSkeleton, {}) }, { children: feedDataRight.map((d, i) => (_jsx(d.component, Object.assign({}, d.componentProps), i))) })) })) })) })))] })));
};
export default forwardRef(Feed);