UNPKG

@selfcommunity/react-ui

Version:

React UI Components to integrate a Community created with SelfCommunity Platform.

423 lines (416 loc) • 22.4 kB
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);