Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0959d1e
feat(feed): widget rail foundation (data, layout, registry)
rebelchris Oct 20, 2026
ea69874
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
4d44279
fix(feed): write the stored widget list back, render a filtered copy
rebelchris Oct 20, 2026
d24da62
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
d6749f3
docs(feed): widget kinds are the client's now
rebelchris Oct 20, 2026
f74586e
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
6661e68
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
47f9853
feat(feed): check each widget's config on the client before it is used
rebelchris Oct 20, 2026
234a33c
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
dccb435
feat(feed): wrapped rail, default from GrowthBook, open sizes, lazy w…
rebelchris Oct 20, 2026
f1c8311
fix(feed): only an edit from the default records startedFrom
rebelchris Oct 20, 2026
34347fc
refactor(feed): read the stored rail from a module free of the widgets
rebelchris Oct 20, 2026
f04be99
refactor(feed): keep the ad column constant with the types
rebelchris Oct 20, 2026
49cdff9
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
002cfbb
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris Oct 21, 2026
5b52b14
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris Oct 21, 2026
653d069
feat(feed): a newer rail is read-only, a newer widget config is left …
rebelchris Oct 21, 2026
d14a920
feat(feed-widgets): cache the hero payload and hold the lead per session
rebelchris Oct 21, 2026
1518beb
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 21, 2026
45491d2
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export interface FeedProps<T>
skipFirstAd?: boolean;
/** The surface leads with a featured card, so keep wide ones out of row one. */
deferWideCards?: boolean;
/** Posts a surface above the feed already shows, kept out of the grid. */
excludedPostIds?: string[];
staticAd?: { ad: Ad; index: number };
disableAdRefresh?: boolean;
allowFetchMore?: boolean;
Expand Down Expand Up @@ -213,6 +215,7 @@ export default function Feed<T>({
disableTopHero,
skipFirstAd,
deferWideCards,
excludedPostIds,
staticAd,
disableAdRefresh = false,
allowFetchMore,
Expand Down Expand Up @@ -380,6 +383,7 @@ export default function Feed<T>({
disableHighlightCards,
skipFirstAd,
deferWideCards,
excludedPostIds,
staticAd,
adPostLength: isSquadFeed ? 2 : undefined,
feedName,
Expand Down
31 changes: 2 additions & 29 deletions packages/shared/src/components/feeds/hero/FeedHero.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Post } from '../../../graphql/posts';
import { gqlClient } from '../../../graphql/common';
import type { FeedHeroData } from '../../../graphql/feed';
import { FEED_HERO_QUERY, baseFeedSupportedTypes } from '../../../graphql/feed';
import type { PostHighlight } from '../../../graphql/highlights';
import type { ViewabilityData } from '../../../features/monetization/viewability';
import { viewabilityLogExtra } from '../../../features/monetization/viewability';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useLogContext } from '../../../contexts/LogContext';
import { useCopyLink } from '../../../hooks/useCopy';
import { ImpressionStatus } from '../../../hooks/feed/useLogImpression';
Expand All @@ -19,11 +14,10 @@ import {
} from '../../../lib/feed';
import { AdActions } from '../../../lib/ads';
import { LogEvent, Origin } from '../../../lib/log';
import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
import { FeedHeroSection } from './FeedHeroSection';
import { useFeedHeroAd } from './useFeedHeroAd';
import { useFeedHeroData } from './useFeedHeroData';
import { useFeedHeroPostActions } from './useFeedHeroPostActions';
import { useCachedTokenRecovery } from '../../../hooks/useCachedTokenRecovery';

/**
* The carousel and the Happening Now list are two lists, not one: `feedHero`
Expand All @@ -48,17 +42,13 @@ export const FeedHero = ({
*/
onRenderedChange?: (isRendered: boolean) => void;
}): ReactElement | null => {
const { user, isTokenValid } = useAuthContext();
const { logEvent } = useLogContext();
const postLogEvent = usePostLogEvent();
const [, copyLink] = useCopyLink();

const { ad, placement, shape } = useFeedHeroAd();

const queryKey = useMemo(
() => generateQueryKey(RequestKey.FeedHero, user),
[user],
);
const { queryKey, posts, highlights } = useFeedHeroData();
// Vote and bookmark patch the hero's own query: the bare hooks only write the
// single-post key, which these cards never read.
const { toggleUpvote, toggleDownvote, toggleBookmark } =
Expand All @@ -68,23 +58,6 @@ export const FeedHero = ({
origin: Origin.FeedHero,
});

const { data: hero } = useQuery({
queryKey,
queryFn: () =>
gqlClient.request<FeedHeroData>(FEED_HERO_QUERY, {
loggedIn: !!user,
supportedTypes: baseFeedSupportedTypes,
}),
enabled: isTokenValid,
// The same window `majorHeadlinesQueryOptions` gives the in-feed card, so
// the two surfaces the experiment compares are equally fresh.
staleTime: StaleTime.OneMinute,
});
useCachedTokenRecovery({ queryKey, enabled: isTokenValid });

const highlights = useMemo(() => hero?.feedHero?.highlights ?? [], [hero]);
const posts: Post[] = useMemo(() => hero?.feedHero?.posts ?? [], [hero]);

const isRendered = posts.length > 0;
const adPlacement = isRendered ? placement : 'none';
const isAdShown = adPlacement !== 'none';
Expand Down
60 changes: 60 additions & 0 deletions packages/shared/src/components/feeds/hero/useFeedHeroCardProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import type { QueryKey } from '@tanstack/react-query';
import type { Post } from '../../../graphql/posts';
import type { FeaturedWideCardProps } from '../../cards/common/featuredWide';
import { useLogContext } from '../../../contexts/LogContext';
import { useCopyLink } from '../../../hooks/useCopy';
import { usePostLogEvent } from '../../../lib/feed';
import { LogEvent, Origin } from '../../../lib/log';
import { useFeedHeroPostActions } from './useFeedHeroPostActions';

/**
* The handlers the hero's cards take, logged under `Origin.FeedHero`. Vote
* and bookmark patch the hero's own query: the bare hooks only write the
* single-post key, which these cards never read.
*/
export const useFeedHeroCardProps = ({
Comment thread
rebelchris marked this conversation as resolved.
queryKey,
feedName,
}: {
queryKey: QueryKey;
feedName: string;
}): Omit<FeaturedWideCardProps, 'post'> => {
const { logEvent } = useLogContext();
const postLogEvent = usePostLogEvent();
const [, copyLink] = useCopyLink();
const { toggleUpvote, toggleDownvote, toggleBookmark } =
useFeedHeroPostActions({
queryKey,
feedName,
origin: Origin.FeedHero,
});

return useMemo(
() => ({
onPostClick: (post: Post) =>
logEvent(
postLogEvent(LogEvent.Click, post, {
extra: { origin: Origin.FeedHero },
}),
),
onUpvoteClick: (post: Post, origin = Origin.FeedHero) =>
toggleUpvote({ payload: post, origin }),
onDownvoteClick: (post: Post, origin = Origin.FeedHero) =>
toggleDownvote({ payload: post, origin }),
onBookmarkClick: (post: Post, origin = Origin.FeedHero) =>
toggleBookmark({ post, origin }),
onCopyLinkClick: (_: MouseEvent, post: Post) =>
copyLink({ link: post.commentsPermalink }),
}),
[
copyLink,
logEvent,
postLogEvent,
toggleBookmark,
toggleDownvote,
toggleUpvote,
],
);
};
56 changes: 56 additions & 0 deletions packages/shared/src/components/feeds/hero/useFeedHeroData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { QueryKey } from '@tanstack/react-query';
import type { Post } from '../../../graphql/posts';
import { gqlClient } from '../../../graphql/common';
import type { FeedHeroData } from '../../../graphql/feed';
import { FEED_HERO_QUERY, baseFeedSupportedTypes } from '../../../graphql/feed';
import type { PostHighlight } from '../../../graphql/highlights';
import { useAuthContext } from '../../../contexts/AuthContext';
import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
import { useCachedTokenRecovery } from '../../../hooks/useCachedTokenRecovery';

const empty: never[] = [];

/**
* The hero's one query, shared by the hero section and the top story and
* Happening Now widgets, so the rail fetches the headlines once however the
* reader arranged them. Vote and bookmark patch this key through
* `useFeedHeroPostActions`.
*/
export const useFeedHeroData = ({
enabled = true,
}: { enabled?: boolean } = {}): {
queryKey: QueryKey;
posts: Post[];
highlights: PostHighlight[];
isPending: boolean;
} => {
const { user, isTokenValid } = useAuthContext();
const queryKey = useMemo(
() => generateQueryKey(RequestKey.FeedHero, user),
[user],
);
const isEnabled = isTokenValid && enabled;

const { data, isPending } = useQuery({
queryKey,
queryFn: () =>
gqlClient.request<FeedHeroData>(FEED_HERO_QUERY, {
loggedIn: !!user,
supportedTypes: baseFeedSupportedTypes,
}),
enabled: isEnabled,
// The same window `majorHeadlinesQueryOptions` gives the in-feed card, so
// the two surfaces the experiment compares are equally fresh.
staleTime: StaleTime.OneMinute,
});
useCachedTokenRecovery({ queryKey, enabled: isEnabled });

return {
queryKey,
posts: data?.feedHero?.posts ?? empty,
highlights: data?.feedHero?.highlights ?? empty,
isPending,
};
};
61 changes: 61 additions & 0 deletions packages/shared/src/features/feedWidgets/FeedWidgetFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';
import { IconSize } from '../../components/Icon';
import { ElementPlaceholder } from '../../components/ElementPlaceholder';
import type { FeedWidgetKind } from './types';
import { FeedWidgetIcon } from './registry';

/**
* The one surface every widget sits on. A widget that is itself a card (a
* post card, the ad) is bare: its own frame is the surface.
*/
export const FeedWidgetSurface = ({
children,
className,
isBare,
}: {
children: ReactNode;
className?: string;
isBare?: boolean;
}): ReactElement => (
<div
className={classNames(
'relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-16',
!isBare &&
'border border-border-subtlest-tertiary bg-background-subtle py-3',
className,
)}
>
{children}
</div>
);

/** What a widget shows while its data loads, and when there is none. */
export const FeedWidgetEmpty = ({
kind,
isPending,
message,
}: {
kind: FeedWidgetKind;
isPending?: boolean;
message: string;
}): ReactElement => {
if (isPending) {
return (
<div className="flex flex-1 flex-col gap-3 px-4" aria-busy>
<ElementPlaceholder className="h-5 w-2/5 rounded-8" />
<ElementPlaceholder className="h-4 w-full rounded-8" />
<ElementPlaceholder className="h-4 w-4/5 rounded-8" />
<ElementPlaceholder className="h-4 w-3/5 rounded-8" />
</div>
);
}

return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center text-text-tertiary">
<FeedWidgetIcon kind={kind} size={IconSize.Large} />
<span className="typo-footnote">{message}</span>
</div>
);
};
30 changes: 30 additions & 0 deletions packages/shared/src/features/feedWidgets/FeedWidgetsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ReactElement, ReactNode } from 'react';
import React, { createContext, useContext, useMemo } from 'react';

export type FeedWidgetsContextData = {
/** For the widgets' click and impression events, as the in-feed cards report. */
feedName: string;
isEditing: boolean;
};

const FeedWidgetsContext = createContext<FeedWidgetsContextData>({
feedName: '',
isEditing: false,
});

export const useFeedWidgetsContext = (): FeedWidgetsContextData =>
useContext(FeedWidgetsContext);

export const FeedWidgetsProvider = ({
feedName,
isEditing,
children,
}: FeedWidgetsContextData & { children: ReactNode }): ReactElement => {
const value = useMemo(() => ({ feedName, isEditing }), [feedName, isEditing]);

return (
<FeedWidgetsContext.Provider value={value}>
{children}
</FeedWidgetsContext.Provider>
);
};
42 changes: 42 additions & 0 deletions packages/shared/src/features/feedWidgets/components.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ComponentType } from 'react';
import type {
FeedWidgetAvailability,
FeedWidgetComponentProps,
FeedWidgetDefinition,
} from './registry';
import { feedWidgetRegistry } from './registry';
import type { FeedWidgetKind, FeedWidgetSize } from './types';
import { TopStoryWidget } from './widgets/TopStoryWidget';
import { HappeningNowWidget } from './widgets/HappeningNowWidget';

/**
* The widgets that exist. A kind the registry names but this map lacks is not
* built yet: hidden from the gallery, and dropped when it is stored.
*/
export const feedWidgetComponents: {
[K in FeedWidgetKind]?: ComponentType<FeedWidgetComponentProps<K>>;
} = {
topStory: TopStoryWidget,
happeningNow: HappeningNowWidget,
};

export const getFeedWidgetComponent = (
kind: FeedWidgetKind,
): ComponentType<FeedWidgetComponentProps> | undefined =>
feedWidgetComponents[kind] as
| ComponentType<FeedWidgetComponentProps>
| undefined;

/** Sizes a kind supports; none when the widget is not built. */
export const feedWidgetSizes = (kind: FeedWidgetKind): FeedWidgetSize[] =>
feedWidgetComponents[kind] ? feedWidgetRegistry[kind].sizes : [];

/** The kinds a reader can add: built, and allowed for their account. */
export const availableFeedWidgets = (
context: FeedWidgetAvailability,
): FeedWidgetDefinition[] =>
(Object.values(feedWidgetRegistry) as FeedWidgetDefinition[]).filter(
(definition) =>
!!feedWidgetComponents[definition.kind] &&
(definition.isAvailable?.(context) ?? true),
);
Loading
Loading