diff --git a/apps/AEPSampleAppNewArchEnabled/app/ContentCardsView.tsx b/apps/AEPSampleAppNewArchEnabled/app/ContentCardsView.tsx index 8a3303a8..33a44746 100644 --- a/apps/AEPSampleAppNewArchEnabled/app/ContentCardsView.tsx +++ b/apps/AEPSampleAppNewArchEnabled/app/ContentCardsView.tsx @@ -12,13 +12,11 @@ governing permissions and limitations under the License. import { MobileCore } from "@adobe/react-native-aepcore"; import { - ContentCardView, ContentCardContainer, + ContentCardView, ThemeProvider, useContentCardUI, - Pagination, - Messaging, - ContentCardContainerProvider, + useContentContainer } from "@adobe/react-native-aepmessaging"; import React, { memo, useCallback, useEffect, useState } from "react"; import { @@ -195,40 +193,21 @@ const MemoHeader = memo(Header); const ContentCardsView = () => { const [selectedView, setSelectedView] = useState('Remote'); - const [trackInput, setTrackInput] = useState(''); - const [containerSettings, setContainerSettings] = useState(null); - const colorScheme = useColorScheme(); - const [currentPage, setCurrentPage] = useState(1); const surface = Platform.OS === "android" ? "rn/android/remote_image" : "rn/ios/remote_image"; - const { content, isLoading, refetch } = useContentCardUI(surface); - - // Load container settings for unread icon configuration - useEffect(() => { - const loadContainerSettings = async () => { - try { - const settings = await Messaging.getContentCardContainer(surface); - setContainerSettings(settings); - // Debug logging - // console.log('Container settings loaded:', JSON.stringify(settings, null, 2)); - // console.log('isUnreadEnabled:', settings?.content?.isUnreadEnabled); - // console.log('unread_indicator:', settings?.content?.unread_indicator); - // console.log('unread_icon image URL:', settings?.content?.unread_indicator?.unread_icon?.image?.url); - // console.log('unread_icon darkUrl:', settings?.content?.unread_indicator?.unread_icon?.image?.darkUrl); - } catch (error) { - console.error('Failed to load container settings:', error); - } - }; - loadContainerSettings(); - }, [surface]); + const { isLoading, refetch } = useContentCardUI(surface); + const { + settings, + error, + isLoading: isLoadingContainer, + refetch: refetchContainer + } = useContentContainer(surface); const items = ITEMS_BY_VIEW[selectedView]; - const colors = colorScheme === "dark" ? Colors.dark : Colors.light; - useEffect(() => { MobileCore.trackAction("small_image"); }, []); @@ -237,48 +216,58 @@ const ContentCardsView = () => { return ( <> {}} + isLoading={isLoading} + onTrackAction={refetchContainer} selectedView={selectedView} setSelectedView={setSelectedView} /> ); } + const renderContentCard = (item: any, isRemote: boolean) => { + const cardView = ; + + if (!isRemote) { + return ( + + + {item.customThemes ? ( + + {cardView} + + ) : ( + cardView + )} + + ); + } + return cardView; + }; + return ( item.key} - renderItem={({ item }: any) => { - const node = ( - - ); - return ( - - - {item.customThemes ? ( - - {node} - - ) : ( - node - )} - - ); - }} + renderItem={({ item }: any) => + renderContentCard(item, false) + } ListHeaderComponent={ - {}} + @@ -298,11 +287,6 @@ const styles = StyleSheet.create({ paddingTop: SPACING.s, paddingBottom: SPACING.s, }, - headerContainer: { - marginTop: 60, - marginBottom: 15, - alignItems: "center", - }, themeSwitcher: { width: "80%", borderRadius: 12, @@ -337,18 +321,6 @@ const styles = StyleSheet.create({ shadowOpacity: 0, elevation: 0, }, - textTitle: { - fontSize: 16, - fontWeight: "600", - }, - textBody: { - fontSize: 14, - lineHeight: SPACING.m, - }, - textCaption: { - fontSize: 12, - fontStyle: "italic", - }, textLabel: { fontSize: 14, }, @@ -420,12 +392,6 @@ const styles = StyleSheet.create({ color: "white", fontWeight: "600", }, - emptyContainer: { - borderRadius: SPACING.s, - padding: SPACING.m, - margin: SPACING.m, - alignItems: "center", - }, listContent: { paddingBottom: SPACING.l, }, diff --git a/packages/messaging/src/Messaging.ts b/packages/messaging/src/Messaging.ts index 95b8b332..b0ef451b 100644 --- a/packages/messaging/src/Messaging.ts +++ b/packages/messaging/src/Messaging.ts @@ -291,7 +291,7 @@ class Messaging { layout: { orientation: "horizontal", }, - capacity: 10, + capacity: 2, emptyStateSettings: { message: { content: "Empty State", @@ -305,7 +305,7 @@ class Messaging { }, }, unread_icon: { - placement: "topright", + placement: "topleft", image: { url: "https://icons.veryicon.com/png/o/leisure/crisp-app-icon-library-v3/notification-5.png", // Image in light mode darkUrl: "", // Empty URL = shows dot in dark mode diff --git a/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.spec.tsx b/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.spec.tsx index ac19a6a5..e843f20a 100644 --- a/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.spec.tsx +++ b/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.spec.tsx @@ -10,19 +10,17 @@ language governing permissions and limitations under the License. */ -import { render, screen } from '@testing-library/react-native'; +import { render, screen, act } from '@testing-library/react-native'; import React from 'react'; import { Dimensions, Text } from 'react-native'; import EmptyState from './EmptyState'; import { ContentCardContainer } from './ContentCardContainer'; -// Mock hooks used by the container jest.mock('../../hooks', () => ({ useContentCardUI: jest.fn(), useContentContainer: jest.fn(), })); -// Capture props passed to ContentCardView (name must start with mock for Jest scope rules) const mockContentCardView: jest.Mock = jest.fn((..._args: any[]) => null); jest.mock('../ContentCardView/ContentCardView', () => { return { @@ -33,7 +31,6 @@ jest.mock('../ContentCardView/ContentCardView', () => { }; }); -// Provide a pass-through for the provider jest.mock('../../providers/ContentCardContainerProvider', () => ({ __esModule: true, default: ({ children }: any) => children, @@ -65,60 +62,54 @@ describe('ContentCardContainer', () => { beforeEach(() => { jest.clearAllMocks(); - // Default Dimensions width for deterministic style assertions jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 800, scale: 2, fontScale: 2 } as any); }); describe('outer container states', () => { it('renders loading state', () => { - (useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: null }); (useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: true, error: null }); const Loading = Loading... as any; const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByText('Loading...')).toBeTruthy(); }); it('renders error state', () => { - (useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: new Error('x') }); (useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null }); const ErrorComp = Error! as any; const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByText('Error!')).toBeTruthy(); }); - it('renders fallback when no content yet', () => { - (useContentContainer as jest.Mock).mockReturnValue({ settings: undefined, isLoading: false, error: null }); + it('renders fallback when no settings provided', () => { (useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null }); const Fallback = Fallback as any; const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByText('Fallback')).toBeTruthy(); }); it('renders outer LoadingComponent when container is loading', () => { - (useContentContainer as jest.Mock).mockReturnValue({ settings: undefined, isLoading: true, error: null }); (useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null }); const Loading = Loading outer... as any; const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByTestId('outer-loading')).toBeTruthy(); }); }); describe('empty content rendering', () => { it('renders empty state when content is empty', () => { - (useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: null }); (useContentCardUI as jest.Mock).mockReturnValue({ content: [], isLoading: false, error: null }); const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByText('No Content Available')).toBeTruthy(); }); @@ -133,11 +124,10 @@ describe('ContentCardContainer', () => { } as any } }; - (useContentContainer as jest.Mock).mockReturnValue({ settings, isLoading: false, error: null }); (useContentCardUI as jest.Mock).mockReturnValue({ content: [], isLoading: false, error: null }); const CC: any = ContentCardContainer; - const { UNSAFE_getByType } = render(); + const { UNSAFE_getByType } = render(); const empty = UNSAFE_getByType(EmptyState); expect(empty.props.image).toBe('https://example.com/light-only.png'); expect(empty.props.text).toBe('No Content Available'); @@ -151,7 +141,7 @@ describe('ContentCardContainer', () => { const template = { id: '1', type: 'SmallImage', data: { content: { title: { content: 'T' }, body: { content: 'B' }, image: { url: 'u' } } } }; (useContentCardUI as jest.Mock).mockReturnValue({ content: [template], isLoading: false, error: null }); const CC: any = ContentCardContainer; - const { getByText } = render(); + const { getByText } = render(); const heading = getByText('Heading'); const styles = Array.isArray(heading.props.style) ? heading.props.style : [heading.props.style]; expect(styles.some((s: any) => s && s.color === '#FFFFFF')).toBe(true); @@ -165,7 +155,7 @@ describe('ContentCardContainer', () => { const ErrorComp = Inner Error! as any; const CC: any = ContentCardContainer; - render(); + render(); expect(screen.getByTestId('inner-error')).toBeTruthy(); }); @@ -177,7 +167,7 @@ describe('ContentCardContainer', () => { {message?.content} ); const CC: any = ContentCardContainer; - render(} />); + render(} />); expect(screen.getByTestId('inner-empty')).toBeTruthy(); expect(screen.getByText('No Content Available')).toBeTruthy(); }); @@ -200,7 +190,7 @@ describe('ContentCardContainer', () => { (useContentCardUI as jest.Mock).mockReturnValue({ content: [template], isLoading: false, error: null }); const CC: any = ContentCardContainer; - render(); + render(); expect(mockContentCardView).toHaveBeenCalled(); const args = mockContentCardView.mock.calls[0][0]; @@ -208,4 +198,34 @@ describe('ContentCardContainer', () => { expect(args.style).toEqual(expect.arrayContaining([expect.anything()])); }); }); + + describe('capacity and dismissal', () => { + it('renders up to capacity and backfills after dismiss', async () => { + const capSettings = { + ...baseSettings, + content: { ...baseSettings.content, capacity: 2 }, + }; + (useContentContainer as jest.Mock).mockReturnValue({ settings: capSettings, isLoading: false, error: null }); + const t1 = { id: '1', type: 'SmallImage', data: { content: { title: { content: 'T1' }, body: { content: 'B1' }, image: { url: 'u1' } } } }; + const t2 = { id: '2', type: 'SmallImage', data: { content: { title: { content: 'T2' }, body: { content: 'B2' }, image: { url: 'u2' } } } }; + const t3 = { id: '3', type: 'SmallImage', data: { content: { title: { content: 'T3' }, body: { content: 'B3' }, image: { url: 'u3' } } } }; + (useContentCardUI as jest.Mock).mockReturnValue({ content: [t1, t2, t3], isLoading: false, error: null }); + + const CC: any = ContentCardContainer; + const utils = render(); + + expect(mockContentCardView.mock.calls.length).toBe(2); + const firstProps = mockContentCardView.mock.calls[0][0]; + + await act(async () => { + firstProps.listener?.('onDismiss', firstProps.template); + }); + utils.rerender( {}} />); + + const renderedIds = mockContentCardView.mock.calls.map(c => c[0].template.id); + expect(renderedIds).toEqual(expect.arrayContaining(['3'])); + const lastTwoIds = renderedIds.slice(-2); + expect(lastTwoIds).not.toEqual(expect.arrayContaining(['1'])); + }); + }); }); \ No newline at end of file diff --git a/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.tsx b/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.tsx index e3d080a3..e05063c2 100644 --- a/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.tsx +++ b/packages/messaging/src/ui/components/ContentCardContainer/ContentCardContainer.tsx @@ -1,4 +1,4 @@ -import { cloneElement, ReactElement, useCallback, useMemo } from "react"; +import { cloneElement, ReactElement, useCallback, useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -9,7 +9,7 @@ import { useColorScheme, useWindowDimensions } from "react-native"; -import { useContentCardUI, useContentContainer } from "../../hooks"; +import { useContentCardUI } from "../../hooks"; import ContentCardContainerProvider, { ContainerSettings, } from "../../providers/ContentCardContainerProvider"; @@ -23,10 +23,13 @@ export interface ContentCardContainerProps extends Partial> FallbackComponent?: ReactElement | null; EmptyComponent?: ReactElement | null; surface: string; + settings: ContainerSettings | null; + isLoading?: boolean; + error?: boolean; CardProps?: Partial; + refetch?: () => Promise; } -// Core renderer: fetches content for a surface, derives layout, and renders a list of cards function ContentCardContainerInner({ contentContainerStyle, LoadingComponent = , @@ -37,6 +40,7 @@ function ContentCardContainerInner({ surface, style, CardProps, + refetch, ...props }: ContentCardContainerProps & { settings: ContainerSettings; @@ -45,19 +49,35 @@ function ContentCardContainerInner({ const { width: windowWidth } = useWindowDimensions(); const { content, error, isLoading } = useContentCardUI(surface); - // Normalize/alias frequently used settings const { content: contentSettings } = settings; - const { heading, layout, emptyStateSettings } = contentSettings; + const { capacity, heading, layout, emptyStateSettings } = contentSettings; + + const [dismissedIds, setDismissedIds] = useState(new Set()); - // Derived flags used across renders const headingColor = useMemo(() => colorScheme === 'dark' ? '#FFFFFF' : '#000000', [colorScheme]); const isHorizontal = useMemo(() => layout?.orientation === 'horizontal', [layout?.orientation]); + const displayCards = useMemo(() => { + const items = (content ?? []) as any[]; + return items.filter((it) => it && !dismissedIds.has(it.id)).slice(0, capacity) as T[]; + }, [content, dismissedIds, capacity]); + const renderItem: ListRenderItem = useCallback(({ item }) => { return ( { + const [event] = args; + if (event === 'onDismiss') { + setDismissedIds((prev) => { + const next = new Set(prev); + next.add((item as any)?.id); + return next; + }); + } + CardProps?.listener?.(...args); + }} style={[ isHorizontal && [ styles.horizontalCardStyles, @@ -103,7 +123,8 @@ function ContentCardContainerInner({ {heading.content} ({ ErrorComponent = null, FallbackComponent = null, surface, + settings, + isLoading, + error, ...props }: ContentCardContainerProps): React.ReactElement { - const { settings, error, isLoading } = useContentContainer(surface); if (isLoading) { return LoadingComponent as React.ReactElement;