From b317aca92f578e08ddcd421b153ea0afb663bca1 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 25 Feb 2026 16:58:11 +0100 Subject: [PATCH 1/9] feat(shared): add PostType.Digest and DigestPostContent component Add digest post type support (Phase 1 & 2): - Add PostType.Digest enum value and DIGEST_SOURCE constant - Add digestPostIds and ad fields to PostFlags type and GraphQL fragments - Create DigestPostContent component that renders a feed of curated posts - Register in CONTENT_MAP and FeedItemComponent card type maps Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 +- .../src/components/FeedItemComponent.tsx | 2 + .../post/digest/DigestPostContent.tsx | 210 ++++++++++++++++++ packages/shared/src/graphql/fragments.ts | 22 ++ packages/shared/src/graphql/posts.ts | 13 ++ packages/shared/src/types.ts | 3 + packages/webapp/pages/posts/[id]/index.tsx | 7 + plans/.gitkeep | 0 8 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/components/post/digest/DigestPostContent.tsx create mode 100644 plans/.gitkeep diff --git a/.gitignore b/.gitignore index 6b3700645e..083f6f912f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ figma-images/ # Build cache *.tsbuildinfo -node-compile-cache/ \ No newline at end of file +node-compile-cache/ + +# Plan files +plans/*.md \ No newline at end of file diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 0857b5578b..d9c254086e 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -108,6 +108,7 @@ const PostTypeToTagCard: Record> = { [PostType.Brief]: BriefCard, [PostType.Poll]: PollGrid, [PostType.SocialTwitter]: SocialTwitterGrid, + [PostType.Digest]: ArticleGrid, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -121,6 +122,7 @@ const PostTypeToTagList: Record> = { [PostType.Brief]: BriefCard, [PostType.Poll]: PollList, [PostType.SocialTwitter]: SocialTwitterList, + [PostType.Digest]: ArticleList, }; const getPostTypeForCard = (post?: Post): PostType => { diff --git a/packages/shared/src/components/post/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx new file mode 100644 index 0000000000..452dd164cc --- /dev/null +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -0,0 +1,210 @@ +import classNames from 'classnames'; +import type { ReactElement } from 'react'; +import React, { useMemo, useEffect } from 'react'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useViewPost } from '../../../hooks/post/useViewPost'; +import { withPostById } from '../withPostById'; +import PostContentContainer from '../PostContentContainer'; +import { BasePostContent } from '../BasePostContent'; +import type { PostContentProps, PostNavigationProps } from '../common'; +import { PostContainer } from '../common'; +import { ToastSubject, useToastNotification } from '../../../hooks'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { AnalyticsIcon } from '../../icons'; +import { CollectionPillSources } from '../collection'; +import { ProfileImageSize } from '../../ProfilePicture'; +import { BriefPostHeaderActions } from '../brief/BriefPostHeaderActions'; +import type { BriefPostHeaderProps } from '../../../features/briefing/components/BriefPostHeader'; +import { BriefPostHeader } from '../../../features/briefing/components/BriefPostHeader'; +import type { FeedProps } from '../../Feed'; +import Feed from '../../Feed'; +import { + FEED_BY_IDS_QUERY, + supportedTypesForPrivateSources, +} from '../../../graphql/feed'; +import { + generateQueryKey, + OtherFeedPage, + RequestKey, +} from '../../../lib/query'; +import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; + +const DigestPostContentRaw = ({ + post, + className = {}, + shouldOnboardAuthor, + origin, + position, + inlineActions, + onPreviousPost, + onNextPost, + onClose, + postPosition, + isFallback, + customNavigation, + backToSquad, + isBannerVisible, + isPostPage, +}: PostContentProps): ReactElement => { + const { user } = useAuthContext(); + const { subject } = useToastNotification(); + const postsCount = post?.flags?.posts || 0; + const sourcesCount = post?.flags?.sources || 0; + const digestPostIds = post?.flags?.digestPostIds; + + const hasNavigation = !!onPreviousPost || !!onNextPost; + const containerClass = classNames( + '!max-w-3xl laptop:flex-row laptop:pb-0', + className?.container, + ); + + const navigationProps: PostNavigationProps = { + postPosition, + onPreviousPost, + onNextPost, + post, + onClose, + inlineActions, + }; + + const onSendViewPost = useViewPost(); + + useEffect(() => { + if (!post?.id || !user?.id) { + return; + } + + onSendViewPost(post.id); + }, [post?.id, onSendViewPost, user?.id]); + + const feedQueryKey = generateQueryKey(RequestKey.FeedByIds, user, post?.id); + + const feedProps = useMemo>(() => { + return { + feedName: OtherFeedPage.FeedByIds, + feedQueryKey, + query: FEED_BY_IDS_QUERY, + variables: { + supportedTypes: supportedTypesForPrivateSources, + postIds: digestPostIds, + }, + disableAds: true, + options: { refetchOnMount: true }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, digestPostIds, post?.id]); + + const formattedDate = post?.createdAt + ? formatDate({ + value: post.createdAt, + type: TimeFormatType.Post, + }) + : ''; + + const headerProps: BriefPostHeaderProps = useMemo( + () => ({ + kicker: formattedDate, + heading: 'Your personalized digest', + stats: [ + ...(postsCount + ? [ + { + Icon: AnalyticsIcon, + label: `${postsCount} posts`, + }, + ] + : []), + ...(sourcesCount + ? [ + { + Icon: AnalyticsIcon, + label: `${sourcesCount} sources`, + }, + ] + : []), + ], + }), + [formattedDate, postsCount, sourcesCount], + ); + + return ( + + + +
+ + + +
+ {post.collectionSources?.length > 0 && ( +
+ + + {sourcesCount ?? 0} Sources + +
+ )} +
+ {!!digestPostIds?.length && } +
+
+
+
+ ); +}; + +export const DigestPostContent = withPostById(DigestPostContentRaw); diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 3042877999..19fc169c72 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -363,6 +363,17 @@ export const SHARED_POST_INFO_FRAGMENT = gql` sources savedTime generatedAt + digestPostIds + ad { + type + index + title + link + image + company_name + company_logo + call_to_action + } } userState { vote @@ -602,6 +613,17 @@ export const FEED_POST_FRAGMENT = gql` posts sources savedTime + digestPostIds + ad { + type + index + title + link + image + company_name + company_logo + call_to_action + } } featuredAward { award { diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 5c55de0b0e..aa89763ab9 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -111,6 +111,17 @@ export type PostTranslation = { [key in TranslateablePostField]?: boolean; }; +export type DigestPostAd = { + type: 'dynamic_ad' | 'ad_plus'; + index: number; + title: string; + link: string; + image: string; + company_name: string; + company_logo: string; + call_to_action: string; +}; + type PostFlags = { sentAnalyticsReport: boolean; banned: boolean; @@ -125,6 +136,8 @@ type PostFlags = { sources?: number; savedTime?: number; generatedAt?: Date; + digestPostIds?: string[]; + ad?: DigestPostAd | null; }; export enum UserVote { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cb052566e6..9b4c098cf4 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -31,6 +31,7 @@ export enum PostType { Brief = 'brief', Poll = 'poll', SocialTwitter = 'social:twitter', + Digest = 'digest', } export const briefSourcesLimit = 6; @@ -42,3 +43,5 @@ export type ErrorBoundaryFeature = | 'extension-feed' | 'onboarding' | '404-page'; + +export const DIGEST_SOURCE = 'digest'; diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 368303caaa..b07c303242 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -108,6 +108,12 @@ const SocialTwitterPostContent = dynamic(() => ).then((module) => module.SocialTwitterPostContent), ); +const DigestPostContent = dynamic(() => + import( + /* webpackChunkName: "lazyDigestPostContent" */ '@dailydotdev/shared/src/components/post/digest/DigestPostContent' + ).then((module) => module.DigestPostContent), +); + export interface Props extends DynamicSeoProps { id: string; initialData?: PostData; @@ -125,6 +131,7 @@ const CONTENT_MAP: Record> = { [PostType.Brief]: BriefPostContent, [PostType.Poll]: PollPostContent, [PostType.SocialTwitter]: SocialTwitterPostContent, + [PostType.Digest]: DigestPostContent, }; export interface PostParams extends ParsedUrlQuery { diff --git a/plans/.gitkeep b/plans/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From 96e947ed62536e613dfe1097e19eaf8c839e3a26 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 25 Feb 2026 20:48:50 +0100 Subject: [PATCH 2/9] feat(shared): add digest ad injection and notification settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inject stored Skadi ads into the digest feed via a new staticAd prop on Feed/useFeed, and add in-app notification controls for digest posts. Phase 3 – Ad injection: - Add staticAd support to useFeed and FeedProps - Transform DigestPostAd → Ad interface in DigestPostContent - Fix missing PostType.Digest in PostModalMap Phase 4 – Notification settings: - Add NotificationType.DigestReady - Create DigestNotification component for in-app toggle - Add digest section to InAppNotificationsTab - Update PersonalizedDigest unsubscribe logic to respect in-app channel Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/components/Feed.tsx | 6 +- .../notifications/DigestNotification.tsx | 175 ++++++++++++++++++ .../notifications/InAppNotificationsTab.tsx | 5 + .../notifications/PersonalizedDigest.tsx | 10 +- .../src/components/notifications/utils.ts | 10 +- .../post/digest/DigestPostContent.tsx | 24 ++- packages/shared/src/hooks/useFeed.ts | 14 ++ 7 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/components/notifications/DigestNotification.tsx diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index fc5e8a3adb..045ed25149 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -12,7 +12,7 @@ import { useRouter } from 'next/router'; import type { QueryKey } from '@tanstack/react-query'; import type { PostItem, UseFeedOptionalParams } from '../hooks/useFeed'; import useFeed, { isBoostedPostAd } from '../hooks/useFeed'; -import type { Post } from '../graphql/posts'; +import type { Ad, Post } from '../graphql/posts'; import { PostType } from '../graphql/posts'; import AuthContext from '../contexts/AuthContext'; import FeedContext from '../contexts/FeedContext'; @@ -86,6 +86,7 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + staticAd?: { ad: Ad; index: number }; allowFetchMore?: boolean; pageSize?: number; isHorizontal?: boolean; @@ -157,6 +158,7 @@ export const PostModalMap: Record = { [PostType.VideoYouTube]: ArticlePostModal, [PostType.Collection]: CollectionPostModal, [PostType.Brief]: BriefPostModal, + [PostType.Digest]: ArticlePostModal, [PostType.Poll]: PollPostModal, [PostType.SocialTwitter]: SocialTwitterPostModal, }; @@ -177,6 +179,7 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + staticAd, allowFetchMore, pageSize, isHorizontal = false, @@ -264,6 +267,7 @@ export default function Feed({ options, settings: { disableAds, + staticAd, adPostLength: isSquadFeed ? 2 : undefined, showAcquisitionForm, ...(showMarketingCta && { marketingCta }), diff --git a/packages/shared/src/components/notifications/DigestNotification.tsx b/packages/shared/src/components/notifications/DigestNotification.tsx new file mode 100644 index 0000000000..072689541e --- /dev/null +++ b/packages/shared/src/components/notifications/DigestNotification.tsx @@ -0,0 +1,175 @@ +import type { SetStateAction } from 'react'; +import React, { useMemo, useState } from 'react'; +import { UserPersonalizedDigestType } from '../../graphql/users'; +import { SendType, usePersonalizedDigest } from '../../hooks'; +import { LogEvent, NotificationCategory } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { HourDropdown } from '../fields/HourDropdown'; +import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; +import useNotificationSettings from '../../hooks/notifications/useNotificationSettings'; +import { NotificationType } from './utils'; +import NotificationSwitch from './NotificationSwitch'; +import { isNullOrUndefined } from '../../lib/func'; +import { Radio } from '../fields/Radio'; + +const digestCopy = `Our recommendation system scans everything on daily.dev and + sends you a tailored digest with just the must-read posts. + Choose when and how often you get them.`; + +const DigestNotification = () => { + const { notificationSettings: ns, toggleSetting } = + useNotificationSettings(); + const { isPushSupported } = usePushNotificationContext(); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { + getPersonalizedDigest, + isLoading, + subscribePersonalizedDigest, + unsubscribePersonalizedDigest, + } = usePersonalizedDigest(); + const [digestTimeIndex, setDigestTimeIndex] = useState(8); + + const digest = useMemo(() => { + if (isLoading) { + return null; + } + + return getPersonalizedDigest(UserPersonalizedDigestType.Digest); + }, [getPersonalizedDigest, isLoading]); + + if ( + !isNullOrUndefined(digest) && + digest?.preferredHour !== digestTimeIndex + ) { + setDigestTimeIndex(digest.preferredHour); + } + + const onLogToggle = (isEnabled: boolean, category: NotificationCategory) => { + logEvent({ + event_name: isEnabled + ? LogEvent.EnableNotification + : LogEvent.DisableNotification, + extra: JSON.stringify({ channel: 'inApp', category }), + }); + }; + + const setCustomTime = ( + type: UserPersonalizedDigestType, + preferredHour: number, + setHour: React.Dispatch>, + ): void => { + logEvent({ + event_name: LogEvent.ScheduleDigest, + extra: JSON.stringify({ + hour: preferredHour, + timezone: user?.timezone, + frequency: digest.flags.sendType, + }), + }); + subscribePersonalizedDigest({ + type, + hour: preferredHour, + sendType: digest.flags.sendType, + flags: digest.flags, + }); + setHour(preferredHour); + }; + + const isChecked = + ns?.[NotificationType.DigestReady]?.inApp === 'subscribed'; + + const onToggleDigest = () => { + toggleSetting(NotificationType.DigestReady, 'inApp'); + onLogToggle(isChecked, NotificationCategory.Digest); + + // Email for digest is managed via BriefingReady in the email tab + const emailActive = + ns?.[NotificationType.BriefingReady]?.email === 'subscribed'; + + if (isChecked && !emailActive) { + // Turning off in-app and email is already off → fully unsubscribe + unsubscribePersonalizedDigest({ + type: UserPersonalizedDigestType.Digest, + }); + } else if (!digest) { + subscribePersonalizedDigest({ + type: UserPersonalizedDigestType.Digest, + sendType: SendType.Workdays, + }); + } + }; + + const onSubscribeDigest = async ({ + type, + sendType, + preferredHour, + }: { + type: UserPersonalizedDigestType; + sendType: SendType; + preferredHour?: number; + }): Promise => { + onLogToggle(true, NotificationCategory.Digest); + + logEvent({ + event_name: LogEvent.ScheduleDigest, + extra: JSON.stringify({ + hour: digestTimeIndex, + timezone: user?.timezone, + frequency: sendType, + type, + }), + }); + + await subscribePersonalizedDigest({ + type, + sendType, + hour: preferredHour ?? digest?.preferredHour, + }); + }; + + return ( +
+ + {!!digest && isChecked && ( + <> +

When to send

+ + setCustomTime(digest.type, hour, setDigestTimeIndex) + } + /> + { + onSubscribeDigest({ + type: digest.type, + sendType, + }); + }} + /> + + )} +
+ ); +}; + +export default DigestNotification; diff --git a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx index 4ceacd198e..3a1df5eea2 100644 --- a/packages/shared/src/components/notifications/InAppNotificationsTab.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationsTab.tsx @@ -32,6 +32,7 @@ import { NotificationPromptSource, } from '../../lib/log'; import { HorizontalSeparator } from '../utilities'; +import DigestNotification from './DigestNotification'; import PresidentialBriefingNotification from './PresidentialBriefingNotification'; import { useLogContext } from '../../contexts/LogContext'; import SquadModNotifications from './SquadModNotifications'; @@ -252,6 +253,10 @@ const InAppNotificationsTab = (): ReactElement => { + + + + diff --git a/packages/shared/src/components/notifications/PersonalizedDigest.tsx b/packages/shared/src/components/notifications/PersonalizedDigest.tsx index acbb0921ae..124060bdb8 100644 --- a/packages/shared/src/components/notifications/PersonalizedDigest.tsx +++ b/packages/shared/src/components/notifications/PersonalizedDigest.tsx @@ -138,9 +138,13 @@ const PersonalizedDigest = () => { if (isChecked) { if (selectedDigest?.type === UserPersonalizedDigestType.Digest) { - unsubscribePersonalizedDigest({ - type: UserPersonalizedDigestType.Digest, - }); + const digestInAppActive = + ns?.[NotificationType.DigestReady]?.inApp === 'subscribed'; + if (!digestInAppActive) { + unsubscribePersonalizedDigest({ + type: UserPersonalizedDigestType.Digest, + }); + } } if ( diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index dfece71c2f..7712612c81 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -65,6 +65,7 @@ export enum NotificationType { UserTopReaderBadge = 'user_given_top_reader', UserReceivedAward = 'user_received_award', BriefingReady = 'briefing_ready', + DigestReady = 'digest_ready', UserFollow = 'user_follow', ArticleUpvoteMilestone = 'article_upvote_milestone', CommentUpvoteMilestone = 'comment_upvote_milestone', @@ -203,6 +204,7 @@ export const notificationTypeTheme: Partial> = [NotificationType.UserTopReaderBadge]: 'text-brand-default', [NotificationType.UserReceivedAward]: 'text-brand-default', [NotificationType.BriefingReady]: 'text-brand-default', + [NotificationType.DigestReady]: 'text-brand-default', [NotificationType.UserFollow]: 'text-brand-default', }; @@ -538,13 +540,11 @@ export const BILLING_NOTIFICATIONS: NotificationItem[] = [ export const isMutingDigestCompletely = ( ns: NotificationSettings, currentChannel: NotificationChannel, + notificationType: NotificationType = NotificationType.BriefingReady, ) => { - const currentChannelStatus = - ns[NotificationType.BriefingReady][currentChannel]; + const currentChannelStatus = ns[notificationType]?.[currentChannel]; const otherChannelStatus = - ns[NotificationType.BriefingReady][ - currentChannel === 'inApp' ? 'email' : 'inApp' - ]; + ns[notificationType]?.[currentChannel === 'inApp' ? 'email' : 'inApp']; return ( otherChannelStatus === 'muted' && currentChannelStatus === 'subscribed' diff --git a/packages/shared/src/components/post/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx index 452dd164cc..24ecec6031 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.tsx +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -32,6 +32,21 @@ import { RequestKey, } from '../../../lib/query'; import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; +import type { Ad, DigestPostAd } from '../../../graphql/posts'; + +const transformDigestAd = ( + digestAd: DigestPostAd, +): { ad: Ad; index: number } => ({ + ad: { + source: 'daily', + company: digestAd.company_name, + description: digestAd.title, + link: digestAd.link, + image: digestAd.image, + pixel: [], + }, + index: digestAd.index, +}); const DigestPostContentRaw = ({ post, @@ -83,6 +98,12 @@ const DigestPostContentRaw = ({ const feedQueryKey = generateQueryKey(RequestKey.FeedByIds, user, post?.id); + const digestAd = post?.flags?.ad; + const staticAd = useMemo( + () => (digestAd ? transformDigestAd(digestAd) : undefined), + [digestAd], + ); + const feedProps = useMemo>(() => { return { feedName: OtherFeedPage.FeedByIds, @@ -93,10 +114,11 @@ const DigestPostContentRaw = ({ postIds: digestPostIds, }, disableAds: true, + staticAd, options: { refetchOnMount: true }, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, digestPostIds, post?.id]); + }, [user, digestPostIds, post?.id, staticAd]); const formattedDate = post?.createdAt ? formatDate({ diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index dd93602116..3452cfd6ef 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -101,6 +101,7 @@ type UseFeedSettingParams = { marketingCta?: MarketingCta; plusEntry?: MarketingCta; feedName?: string; + staticAd?: { ad: Ad; index: number }; }; export interface UseFeedOptionalParams { @@ -383,6 +384,18 @@ export default function useFeed( }), ); } + + if (settings?.staticAd && newItems.length > 0) { + const insertAt = Math.min(settings.staticAd.index, newItems.length); + newItems.splice(insertAt, 0, { + type: FeedItemType.Ad, + ad: settings.staticAd.ad, + index: 0, + updatedAt: Date.now(), + dataUpdatedAt: Date.now(), + } as AdItem); + } + return newItems; }, [ feedQuery.data, @@ -393,6 +406,7 @@ export default function useFeed( placeholdersPerPage, getAd, settings.plusEntry, + settings.staticAd, ]); const updatePost = updateCachedPagePost(feedQueryKey, queryClient); From d6c09d8f4d4fe5781741d73ef42b32cc0f722d7b Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 25 Feb 2026 21:18:56 +0100 Subject: [PATCH 3/9] test(shared): add tests for transformDigestAd and isMutingDigestCompletely Extract transformDigestAd to digest/utils.ts for testability and add unit tests for the digest ad transformation and notification muting logic. Co-Authored-By: Claude Opus 4.6 --- .../notifications/DigestNotification.tsx | 11 +- .../components/notifications/utils.spec.ts | 119 ++++++++++++++++++ .../post/digest/DigestPostContent.spec.ts | 82 ++++++++++++ .../post/digest/DigestPostContent.tsx | 16 +-- .../src/components/post/digest/utils.ts | 15 +++ 5 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 packages/shared/src/components/notifications/utils.spec.ts create mode 100644 packages/shared/src/components/post/digest/DigestPostContent.spec.ts create mode 100644 packages/shared/src/components/post/digest/utils.ts diff --git a/packages/shared/src/components/notifications/DigestNotification.tsx b/packages/shared/src/components/notifications/DigestNotification.tsx index 072689541e..bae16fbf08 100644 --- a/packages/shared/src/components/notifications/DigestNotification.tsx +++ b/packages/shared/src/components/notifications/DigestNotification.tsx @@ -18,8 +18,7 @@ const digestCopy = `Our recommendation system scans everything on daily.dev and Choose when and how often you get them.`; const DigestNotification = () => { - const { notificationSettings: ns, toggleSetting } = - useNotificationSettings(); + const { notificationSettings: ns, toggleSetting } = useNotificationSettings(); const { isPushSupported } = usePushNotificationContext(); const { user } = useAuthContext(); const { logEvent } = useLogContext(); @@ -39,10 +38,7 @@ const DigestNotification = () => { return getPersonalizedDigest(UserPersonalizedDigestType.Digest); }, [getPersonalizedDigest, isLoading]); - if ( - !isNullOrUndefined(digest) && - digest?.preferredHour !== digestTimeIndex - ) { + if (!isNullOrUndefined(digest) && digest?.preferredHour !== digestTimeIndex) { setDigestTimeIndex(digest.preferredHour); } @@ -77,8 +73,7 @@ const DigestNotification = () => { setHour(preferredHour); }; - const isChecked = - ns?.[NotificationType.DigestReady]?.inApp === 'subscribed'; + const isChecked = ns?.[NotificationType.DigestReady]?.inApp === 'subscribed'; const onToggleDigest = () => { toggleSetting(NotificationType.DigestReady, 'inApp'); diff --git a/packages/shared/src/components/notifications/utils.spec.ts b/packages/shared/src/components/notifications/utils.spec.ts new file mode 100644 index 0000000000..560b7a9ca2 --- /dev/null +++ b/packages/shared/src/components/notifications/utils.spec.ts @@ -0,0 +1,119 @@ +import { isMutingDigestCompletely, NotificationType } from './utils'; +import type { NotificationSettings } from './utils'; + +describe('isMutingDigestCompletely', () => { + describe('with default BriefingReady type', () => { + it('should return true when other channel is muted and current is subscribed', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + email: 'muted', + inApp: 'subscribed', + }, + }; + + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(true); + }); + + it('should return false when other channel is subscribed', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + email: 'subscribed', + inApp: 'subscribed', + }, + }; + + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false); + }); + + it('should return false when current channel is already muted', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + email: 'muted', + inApp: 'muted', + }, + }; + + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false); + }); + + it('should return true when checking email channel with inApp muted', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + email: 'subscribed', + inApp: 'muted', + }, + }; + + expect(isMutingDigestCompletely(ns, 'email')).toBe(true); + }); + }); + + describe('with DigestReady type', () => { + it('should return true when other channel is muted and current is subscribed', () => { + const ns: NotificationSettings = { + [NotificationType.DigestReady]: { + email: 'muted', + inApp: 'subscribed', + }, + }; + + expect( + isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady), + ).toBe(true); + }); + + it('should return false when other channel is subscribed', () => { + const ns: NotificationSettings = { + [NotificationType.DigestReady]: { + email: 'subscribed', + inApp: 'subscribed', + }, + }; + + expect( + isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady), + ).toBe(false); + }); + + it('should not be affected by BriefingReady settings', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + email: 'muted', + inApp: 'subscribed', + }, + [NotificationType.DigestReady]: { + email: 'subscribed', + inApp: 'subscribed', + }, + }; + + // DigestReady has both subscribed, so should be false + expect( + isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady), + ).toBe(false); + + // BriefingReady has email muted, so should be true with default + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should return false when notification type is not in settings', () => { + const ns: NotificationSettings = {}; + + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false); + }); + + it('should return false when settings are partially defined', () => { + const ns: NotificationSettings = { + [NotificationType.BriefingReady]: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + email: undefined as any, + inApp: 'subscribed', + }, + }; + + expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/components/post/digest/DigestPostContent.spec.ts b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts new file mode 100644 index 0000000000..8dcd970a7d --- /dev/null +++ b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts @@ -0,0 +1,82 @@ +import type { DigestPostAd } from '../../../graphql/posts'; +import { transformDigestAd } from './utils'; + +describe('transformDigestAd', () => { + it('should transform DigestPostAd to Ad with correct field mapping', () => { + const digestAd: DigestPostAd = { + type: 'dynamic_ad', + index: 3, + title: 'Check out our product', + link: 'https://example.com/ad', + image: 'https://example.com/ad-image.png', + company_name: 'Acme Corp', + company_logo: 'https://example.com/logo.png', + call_to_action: 'Learn More', + }; + + const result = transformDigestAd(digestAd); + + expect(result).toEqual({ + ad: { + source: 'daily', + company: 'Acme Corp', + description: 'Check out our product', + link: 'https://example.com/ad', + image: 'https://example.com/ad-image.png', + pixel: [], + }, + index: 3, + }); + }); + + it('should set source to daily regardless of ad type', () => { + const digestAd: DigestPostAd = { + type: 'ad_plus', + index: 1, + title: 'Upgrade to Plus', + link: 'https://daily.dev/plus', + image: 'https://daily.dev/plus-image.png', + company_name: 'daily.dev', + company_logo: 'https://daily.dev/logo.png', + call_to_action: 'Upgrade', + }; + + const result = transformDigestAd(digestAd); + + expect(result.ad.source).toBe('daily'); + }); + + it('should always set pixel to empty array', () => { + const digestAd: DigestPostAd = { + type: 'dynamic_ad', + index: 0, + title: 'Ad title', + link: 'https://example.com', + image: 'https://example.com/img.png', + company_name: 'Company', + company_logo: 'https://example.com/logo.png', + call_to_action: 'Click', + }; + + const result = transformDigestAd(digestAd); + + expect(result.ad.pixel).toEqual([]); + }); + + it('should preserve the index from the digest ad', () => { + const digestAd: DigestPostAd = { + type: 'dynamic_ad', + index: 7, + title: 'Ad', + link: 'https://example.com', + image: 'https://example.com/img.png', + company_name: 'Company', + company_logo: 'https://example.com/logo.png', + call_to_action: 'Click', + }; + + const result = transformDigestAd(digestAd); + + expect(result.index).toBe(7); + }); +}); diff --git a/packages/shared/src/components/post/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx index 24ecec6031..340a40b4ca 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.tsx +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -32,21 +32,7 @@ import { RequestKey, } from '../../../lib/query'; import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; -import type { Ad, DigestPostAd } from '../../../graphql/posts'; - -const transformDigestAd = ( - digestAd: DigestPostAd, -): { ad: Ad; index: number } => ({ - ad: { - source: 'daily', - company: digestAd.company_name, - description: digestAd.title, - link: digestAd.link, - image: digestAd.image, - pixel: [], - }, - index: digestAd.index, -}); +import { transformDigestAd } from './utils'; const DigestPostContentRaw = ({ post, diff --git a/packages/shared/src/components/post/digest/utils.ts b/packages/shared/src/components/post/digest/utils.ts new file mode 100644 index 0000000000..e16ceb2a77 --- /dev/null +++ b/packages/shared/src/components/post/digest/utils.ts @@ -0,0 +1,15 @@ +import type { Ad, DigestPostAd } from '../../../graphql/posts'; + +export const transformDigestAd = ( + digestAd: DigestPostAd, +): { ad: Ad; index: number } => ({ + ad: { + source: 'daily', + company: digestAd.company_name, + description: digestAd.title, + link: digestAd.link, + image: digestAd.image, + pixel: [], + }, + index: digestAd.index, +}); From c7b01cee3de03e2f19eb8ef5fd3b958798d623ac Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 25 Feb 2026 21:35:38 +0100 Subject: [PATCH 4/9] fix(shared): remove ad_plus type, only support dynamic_ad for digest Co-Authored-By: Claude Opus 4.6 --- .../post/digest/DigestPostContent.spec.ts | 17 ----------------- packages/shared/src/graphql/posts.ts | 2 +- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/shared/src/components/post/digest/DigestPostContent.spec.ts b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts index 8dcd970a7d..979d5f9b24 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.spec.ts +++ b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts @@ -29,23 +29,6 @@ describe('transformDigestAd', () => { }); }); - it('should set source to daily regardless of ad type', () => { - const digestAd: DigestPostAd = { - type: 'ad_plus', - index: 1, - title: 'Upgrade to Plus', - link: 'https://daily.dev/plus', - image: 'https://daily.dev/plus-image.png', - company_name: 'daily.dev', - company_logo: 'https://daily.dev/logo.png', - call_to_action: 'Upgrade', - }; - - const result = transformDigestAd(digestAd); - - expect(result.ad.source).toBe('daily'); - }); - it('should always set pixel to empty array', () => { const digestAd: DigestPostAd = { type: 'dynamic_ad', diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index aa89763ab9..22edafec2d 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -112,7 +112,7 @@ export type PostTranslation = { }; export type DigestPostAd = { - type: 'dynamic_ad' | 'ad_plus'; + type: 'dynamic_ad'; index: number; title: string; link: string; From 600ee368a1e9646660023d39f4f4450b72d95022 Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 25 Feb 2026 23:19:38 +0100 Subject: [PATCH 5/9] fix(shared): use camelCase for DigestAd fields from GraphQL API returns camelCase (companyName, companyLogo, callToAction) instead of snake_case. Update type, fragments, transform utils, and tests. Co-Authored-By: Claude Opus 4.6 --- .../post/digest/DigestPostContent.spec.ts | 18 +++++++++--------- .../shared/src/components/post/digest/utils.ts | 2 +- packages/shared/src/graphql/fragments.ts | 12 ++++++------ packages/shared/src/graphql/posts.ts | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/components/post/digest/DigestPostContent.spec.ts b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts index 979d5f9b24..5d77f9ba3f 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.spec.ts +++ b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts @@ -9,9 +9,9 @@ describe('transformDigestAd', () => { title: 'Check out our product', link: 'https://example.com/ad', image: 'https://example.com/ad-image.png', - company_name: 'Acme Corp', - company_logo: 'https://example.com/logo.png', - call_to_action: 'Learn More', + companyName: 'Acme Corp', + companyLogo: 'https://example.com/logo.png', + callToAction: 'Learn More', }; const result = transformDigestAd(digestAd); @@ -36,9 +36,9 @@ describe('transformDigestAd', () => { title: 'Ad title', link: 'https://example.com', image: 'https://example.com/img.png', - company_name: 'Company', - company_logo: 'https://example.com/logo.png', - call_to_action: 'Click', + companyName: 'Company', + companyLogo: 'https://example.com/logo.png', + callToAction: 'Click', }; const result = transformDigestAd(digestAd); @@ -53,9 +53,9 @@ describe('transformDigestAd', () => { title: 'Ad', link: 'https://example.com', image: 'https://example.com/img.png', - company_name: 'Company', - company_logo: 'https://example.com/logo.png', - call_to_action: 'Click', + companyName: 'Company', + companyLogo: 'https://example.com/logo.png', + callToAction: 'Click', }; const result = transformDigestAd(digestAd); diff --git a/packages/shared/src/components/post/digest/utils.ts b/packages/shared/src/components/post/digest/utils.ts index e16ceb2a77..492b445310 100644 --- a/packages/shared/src/components/post/digest/utils.ts +++ b/packages/shared/src/components/post/digest/utils.ts @@ -5,7 +5,7 @@ export const transformDigestAd = ( ): { ad: Ad; index: number } => ({ ad: { source: 'daily', - company: digestAd.company_name, + company: digestAd.companyName, description: digestAd.title, link: digestAd.link, image: digestAd.image, diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 19fc169c72..dc7872ee49 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -370,9 +370,9 @@ export const SHARED_POST_INFO_FRAGMENT = gql` title link image - company_name - company_logo - call_to_action + companyName + companyLogo + callToAction } } userState { @@ -620,9 +620,9 @@ export const FEED_POST_FRAGMENT = gql` title link image - company_name - company_logo - call_to_action + companyName + companyLogo + callToAction } } featuredAward { diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 22edafec2d..2b2b07857e 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -117,9 +117,9 @@ export type DigestPostAd = { title: string; link: string; image: string; - company_name: string; - company_logo: string; - call_to_action: string; + companyName: string; + companyLogo: string; + callToAction: string; }; type PostFlags = { From c749d79f93fcad6655ae8b40e12086d5947dbb23 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 26 Feb 2026 12:43:49 +0100 Subject: [PATCH 6/9] fix: address PR review feedback - Only inject static ad when real feed data exists, not during loading - Remove digest fields from SHARED_POST_INFO_FRAGMENT (only needed in FEED_POST_FRAGMENT) - Remove unused DIGEST_SOURCE constant Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/graphql/fragments.ts | 11 ----------- packages/shared/src/hooks/useFeed.ts | 2 +- packages/shared/src/types.ts | 1 - 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index dc7872ee49..a10c4d93ba 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -363,17 +363,6 @@ export const SHARED_POST_INFO_FRAGMENT = gql` sources savedTime generatedAt - digestPostIds - ad { - type - index - title - link - image - companyName - companyLogo - callToAction - } } userState { vote diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 3452cfd6ef..8f46a54e82 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -385,7 +385,7 @@ export default function useFeed( ); } - if (settings?.staticAd && newItems.length > 0) { + if (settings?.staticAd && feedQuery.data && newItems.length > 0) { const insertAt = Math.min(settings.staticAd.index, newItems.length); newItems.splice(insertAt, 0, { type: FeedItemType.Ad, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9b4c098cf4..b4baaa02fa 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -44,4 +44,3 @@ export type ErrorBoundaryFeature = | 'onboarding' | '404-page'; -export const DIGEST_SOURCE = 'digest'; From 38b7aef6af02f44f99f2104b1995bcf277477247 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 26 Feb 2026 19:15:21 +0100 Subject: [PATCH 7/9] feat(digest): force list mode via context override and hide ad refresh Override insaneMode via SettingsContext.Provider to force list layout in digest post feed. Add disableAdRefresh prop to hide reload button on static ads. Add digestPostIds and ad fields to SHARED_POST_INFO_FRAGMENT. Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/components/Feed.tsx | 6 ++++-- .../src/components/FeedItemComponent.tsx | 8 +++++++- .../shared/src/components/cards/ad/AdGrid.tsx | 14 ++++++++------ .../shared/src/components/cards/ad/AdList.tsx | 4 +++- .../post/digest/DigestPostContent.tsx | 18 ++++++++++++++++-- packages/shared/src/graphql/fragments.ts | 11 +++++++++++ packages/shared/src/hooks/useFeedLayout.ts | 6 +++++- packages/shared/src/lib/query.ts | 1 + 8 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 045ed25149..dbfe377d41 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -87,6 +87,7 @@ export interface FeedProps actionButtons?: ReactNode; disableAds?: boolean; staticAd?: { ad: Ad; index: number }; + disableAdRefresh?: boolean; allowFetchMore?: boolean; pageSize?: number; isHorizontal?: boolean; @@ -180,6 +181,7 @@ export default function Feed({ actionButtons, disableAds, staticAd, + disableAdRefresh = false, allowFetchMore, pageSize, isHorizontal = false, @@ -191,10 +193,9 @@ export default function Feed({ const { user } = useContext(AuthContext); const { isFallback, query: routerQuery } = useRouter(); const { openNewTab, spaciness, loadedSettings } = useContext(SettingsContext); - const { isListMode } = useFeedLayout(); + const { isListMode, shouldUseListFeedLayout } = useFeedLayout(); const numCards = currentSettings.numCards[spaciness ?? 'eco']; const isSquadFeed = feedName === OtherFeedPage.Squad; - const { shouldUseListFeedLayout } = useFeedLayout(); const trackedFeedFinish = useRef(false); const isMyFeed = feedName === SharedFeedPage.MyFeed; const showAcquisitionForm = @@ -625,6 +626,7 @@ export default function Feed({ onCommentClick={onCommentClick} onReadArticleClick={onReadArticleClick} virtualizedNumCards={virtualizedNumCards} + disableAdRefresh={disableAdRefresh} /> ))} diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index d9c254086e..8fc04be824 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -83,6 +83,7 @@ export type FeedItemComponentProps = { isAd?: boolean, ) => unknown; virtualizedNumCards: number; + disableAdRefresh?: boolean; } & Pick & Pick; @@ -236,6 +237,7 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, + disableAdRefresh, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( @@ -381,7 +383,11 @@ function FeedItemComponent({ index={item.index} feedIndex={index} onLinkClick={(ad: Ad) => onAdAction(AdActions.Click, ad)} - onRefresh={(ad: Ad) => onAdAction(AdActions.Refresh, ad)} + onRefresh={ + disableAdRefresh + ? undefined + : (ad: Ad) => onAdAction(AdActions.Refresh, ad) + } /> ); case FeedItemType.UserAcquisition: diff --git a/packages/shared/src/components/cards/ad/AdGrid.tsx b/packages/shared/src/components/cards/ad/AdGrid.tsx index 6bbfe49b8f..33ed6a42a3 100644 --- a/packages/shared/src/components/cards/ad/AdGrid.tsx +++ b/packages/shared/src/components/cards/ad/AdGrid.tsx @@ -53,12 +53,14 @@ export const AdGrid = forwardRef(function AdGrid(
- + {!!onRefresh && ( + + )} {!isPlus && (
- + {!!onRefresh && ( + + )} {!isPlus && }
diff --git a/packages/shared/src/components/post/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx index 340a40b4ca..c9f99cea86 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.tsx +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -2,6 +2,9 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; import React, { useMemo, useEffect } from 'react'; import { useAuthContext } from '../../../contexts/AuthContext'; +import SettingsContext, { + useSettingsContext, +} from '../../../contexts/SettingsContext'; import { useViewPost } from '../../../hooks/post/useViewPost'; import { withPostById } from '../withPostById'; import PostContentContainer from '../PostContentContainer'; @@ -53,6 +56,12 @@ const DigestPostContentRaw = ({ }: PostContentProps): ReactElement => { const { user } = useAuthContext(); const { subject } = useToastNotification(); + const settingsContext = useSettingsContext(); + // ensure digest feed renders list mode + const digestSettings = useMemo( + () => ({ ...settingsContext, insaneMode: true }), + [settingsContext], + ); const postsCount = post?.flags?.posts || 0; const sourcesCount = post?.flags?.sources || 0; const digestPostIds = post?.flags?.digestPostIds; @@ -101,6 +110,7 @@ const DigestPostContentRaw = ({ }, disableAds: true, staticAd, + disableAdRefresh: true, options: { refetchOnMount: true }, }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -178,7 +188,7 @@ const DigestPostContentRaw = ({ origin={origin} post={post} > -
+
)}
- {!!digestPostIds?.length && } + {!!digestPostIds?.length && ( + + + + )}
diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index a10c4d93ba..dc7872ee49 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -363,6 +363,17 @@ export const SHARED_POST_INFO_FRAGMENT = gql` sources savedTime generatedAt + digestPostIds + ad { + type + index + title + link + image + companyName + companyLogo + callToAction + } } userState { vote diff --git a/packages/shared/src/hooks/useFeedLayout.ts b/packages/shared/src/hooks/useFeedLayout.ts index 5aca1904a0..7f77e6d20d 100644 --- a/packages/shared/src/hooks/useFeedLayout.ts +++ b/packages/shared/src/hooks/useFeedLayout.ts @@ -77,6 +77,8 @@ export const UserProfileFeedPages = new Set([ OtherFeedPage.UserPosts, ]); +export const PostFeedPages = new Set([OtherFeedPage.Post]); + interface GetFeedPageLayoutComponentProps extends Pick< UseFeedLayoutReturn, @@ -116,6 +118,8 @@ export const useFeedLayout = ({ const { insaneMode } = useContext(SettingsContext); const { isSearchPageLaptop } = useSearchResultsLayout(); + const isPostFeedPage = PostFeedPages.has(feedName as OtherFeedPage); + const isListMode = isSearchPageLaptop || insaneMode; const shouldUseListFeedLayoutOnProfilePages = UserProfileFeedPages.has( @@ -128,7 +132,7 @@ export const useFeedLayout = ({ !isLaptop && isFeedIncludedInListLayout; const shouldUseListMode = - isListMode && isLaptop && isFeedIncludedInListLayout; + (isListMode && isLaptop && isFeedIncludedInListLayout) || isPostFeedPage; const shouldUseListFeedLayout = feedRelated ? shouldUseListFeedLayoutOnMobileTablet || diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 45c02cecbc..6f1836456e 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -64,6 +64,7 @@ export enum OtherFeedPage { Welcome = 'welcome', Discussed = 'discussed', Following = 'following', + Post = 'posts[id]', } const ONE_MINUTE = 60 * 1000; From d85c53e456bda90b02402b4a0de23a6011ed5b01 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 26 Feb 2026 19:21:09 +0100 Subject: [PATCH 8/9] feat(notifications): add digest avatar type for notification icon Add Digest to NotificationAvatarType enum and render BriefIcon for digest notifications so the avatar displays correctly. Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationItemAvatar.tsx | 10 +++++++++- packages/shared/src/graphql/notifications.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx index ad53e5910d..b6d017c481 100644 --- a/packages/shared/src/components/notifications/NotificationItemAvatar.tsx +++ b/packages/shared/src/components/notifications/NotificationItemAvatar.tsx @@ -6,7 +6,7 @@ import SourceButton from '../cards/common/SourceButton'; import { ProfileTooltip } from '../profile/ProfileTooltip'; import { ProfileImageLink } from '../profile/ProfileImageLink'; import { ProfileImageSize } from '../ProfilePicture'; -import { BriefGradientIcon, MedalBadgeIcon } from '../icons'; +import { BriefGradientIcon, BriefIcon, MedalBadgeIcon } from '../icons'; import { IconSize } from '../Icon'; import { BadgeIconGoldGradient } from '../badges/BadgeIcon'; import { Image, ImageType } from '../image/Image'; @@ -85,6 +85,14 @@ function NotificationItemAvatar({ ); } + if (type === NotificationAvatarType.Digest) { + return ( + + + + ); + } + if (type === NotificationAvatarType.Achievement) { return ( Date: Thu, 26 Feb 2026 19:38:13 +0100 Subject: [PATCH 9/9] feat(digest): add Plus upsell below digest post feed Show BriefUpgradeAlert below digest feed for non-Plus users. Add text prop to BriefUpgradeAlert for customizable copy. Co-Authored-By: Claude Opus 4.6 --- .../src/components/post/digest/DigestPostContent.tsx | 11 ++++++++++- .../briefing/components/BriefUpgradeAlert.tsx | 8 +++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/post/digest/DigestPostContent.tsx b/packages/shared/src/components/post/digest/DigestPostContent.tsx index c9f99cea86..2d234256c7 100644 --- a/packages/shared/src/components/post/digest/DigestPostContent.tsx +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -5,6 +5,7 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import SettingsContext, { useSettingsContext, } from '../../../contexts/SettingsContext'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; import { useViewPost } from '../../../hooks/post/useViewPost'; import { withPostById } from '../withPostById'; import PostContentContainer from '../PostContentContainer'; @@ -35,6 +36,7 @@ import { RequestKey, } from '../../../lib/query'; import { formatDate, TimeFormatType } from '../../../lib/dateFormat'; +import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert'; import { transformDigestAd } from './utils'; const DigestPostContentRaw = ({ @@ -54,7 +56,8 @@ const DigestPostContentRaw = ({ isBannerVisible, isPostPage, }: PostContentProps): ReactElement => { - const { user } = useAuthContext(); + const { user, isLoggedIn } = useAuthContext(); + const { isPlus } = usePlusSubscription(); const { subject } = useToastNotification(); const settingsContext = useSettingsContext(); // ensure digest feed renders list mode @@ -222,6 +225,12 @@ const DigestPostContentRaw = ({ )} + {isLoggedIn && !isPlus && ( + + )}
diff --git a/packages/shared/src/features/briefing/components/BriefUpgradeAlert.tsx b/packages/shared/src/features/briefing/components/BriefUpgradeAlert.tsx index 617de99255..e471fd1161 100644 --- a/packages/shared/src/features/briefing/components/BriefUpgradeAlert.tsx +++ b/packages/shared/src/features/briefing/components/BriefUpgradeAlert.tsx @@ -10,8 +10,11 @@ import { BriefPlusUpgradeCTA } from './BriefPlusUpgradeCTA'; export const BriefUpgradeAlert = ({ className, + text = 'Get unlimited access to every past and future presidential briefing with daily.dev Plus.', ...attrs -}: ComponentProps<'div'>) => { +}: ComponentProps<'div'> & { + text?: string; +}) => { return (
- Get unlimited access to every past and future presidential briefing with - daily.dev Plus. + {text}