Repository navigation
feat(feed): widget rail foundation (data, layout, registry) #6963
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
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 ea69874
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris 4d44279
fix(feed): write the stored widget list back, render a filtered copy
rebelchris d24da62
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris d6749f3
docs(feed): widget kinds are the client's now
rebelchris f74586e
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris 6661e68
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris 47f9853
feat(feed): check each widget's config on the client before it is used
rebelchris 234a33c
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris dccb435
feat(feed): wrapped rail, default from GrowthBook, open sizes, lazy w…
rebelchris f1c8311
fix(feed): only an edit from the default records startedFrom
rebelchris 34347fc
refactor(feed): read the stored rail from a module free of the widgets
rebelchris f04be99
refactor(feed): keep the ad column constant with the types
rebelchris 49cdff9
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris 002cfbb
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris 5b52b14
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris 653d069
feat(feed): a newer rail is read-only, a newer widget config is left …
rebelchris d14a920
feat(feed-widgets): cache the hero payload and hold the lead per session
rebelchris 1518beb
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris 45491d2
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
packages/shared/src/components/feeds/hero/useFeedHeroCardProps.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = ({ | ||
| 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
56
packages/shared/src/components/feeds/hero/useFeedHeroData.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
61
packages/shared/src/features/feedWidgets/FeedWidgetFrame.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
30
packages/shared/src/features/feedWidgets/FeedWidgetsContext.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.