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/Feed.tsx b/packages/shared/src/components/Feed.tsx index fc5e8a3adb..dbfe377d41 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,8 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + staticAd?: { ad: Ad; index: number }; + disableAdRefresh?: boolean; allowFetchMore?: boolean; pageSize?: number; isHorizontal?: boolean; @@ -157,6 +159,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 +180,8 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + staticAd, + disableAdRefresh = false, allowFetchMore, pageSize, isHorizontal = false, @@ -188,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 = @@ -264,6 +268,7 @@ export default function Feed({ options, settings: { disableAds, + staticAd, adPostLength: isSquadFeed ? 2 : undefined, showAcquisitionForm, ...(showMarketingCta && { marketingCta }), @@ -621,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 0857b5578b..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; @@ -108,6 +109,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 +123,7 @@ const PostTypeToTagList: Record> = { [PostType.Brief]: BriefCard, [PostType.Poll]: PollList, [PostType.SocialTwitter]: SocialTwitterList, + [PostType.Digest]: ArticleList, }; const getPostTypeForCard = (post?: Post): PostType => { @@ -234,6 +237,7 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, + disableAdRefresh, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( @@ -379,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/notifications/DigestNotification.tsx b/packages/shared/src/components/notifications/DigestNotification.tsx new file mode 100644 index 0000000000..bae16fbf08 --- /dev/null +++ b/packages/shared/src/components/notifications/DigestNotification.tsx @@ -0,0 +1,170 @@ +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/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 ( { 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.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/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index f3ca1f3086..5c29eff733 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.spec.ts b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts new file mode 100644 index 0000000000..5d77f9ba3f --- /dev/null +++ b/packages/shared/src/components/post/digest/DigestPostContent.spec.ts @@ -0,0 +1,65 @@ +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', + companyName: 'Acme Corp', + companyLogo: 'https://example.com/logo.png', + callToAction: '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 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', + companyName: 'Company', + companyLogo: 'https://example.com/logo.png', + callToAction: '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', + companyName: 'Company', + companyLogo: 'https://example.com/logo.png', + callToAction: '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 new file mode 100644 index 0000000000..2d234256c7 --- /dev/null +++ b/packages/shared/src/components/post/digest/DigestPostContent.tsx @@ -0,0 +1,241 @@ +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 { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +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'; +import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert'; +import { transformDigestAd } from './utils'; + +const DigestPostContentRaw = ({ + post, + className = {}, + shouldOnboardAuthor, + origin, + position, + inlineActions, + onPreviousPost, + onNextPost, + onClose, + postPosition, + isFallback, + customNavigation, + backToSquad, + isBannerVisible, + isPostPage, +}: PostContentProps): ReactElement => { + const { user, isLoggedIn } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + 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; + + 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 digestAd = post?.flags?.ad; + const staticAd = useMemo( + () => (digestAd ? transformDigestAd(digestAd) : undefined), + [digestAd], + ); + + const feedProps = useMemo>(() => { + return { + feedName: OtherFeedPage.FeedByIds, + feedQueryKey, + query: FEED_BY_IDS_QUERY, + variables: { + supportedTypes: supportedTypesForPrivateSources, + postIds: digestPostIds, + }, + disableAds: true, + staticAd, + disableAdRefresh: true, + options: { refetchOnMount: true }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, digestPostIds, post?.id, staticAd]); + + 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 && ( + + + + )} + {isLoggedIn && !isPlus && ( + + )} +
+
+
+
+ ); +}; + +export const DigestPostContent = withPostById(DigestPostContentRaw); 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..492b445310 --- /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.companyName, + description: digestAd.title, + link: digestAd.link, + image: digestAd.image, + pixel: [], + }, + index: digestAd.index, +}); 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}
diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 3042877999..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 @@ -602,6 +613,17 @@ export const FEED_POST_FRAGMENT = gql` posts sources savedTime + digestPostIds + ad { + type + index + title + link + image + companyName + companyLogo + callToAction + } } featuredAward { award { diff --git a/packages/shared/src/graphql/notifications.ts b/packages/shared/src/graphql/notifications.ts index b8a6fd1e57..67bdbae7a7 100644 --- a/packages/shared/src/graphql/notifications.ts +++ b/packages/shared/src/graphql/notifications.ts @@ -12,6 +12,7 @@ export enum NotificationAvatarType { TopReaderBadge = 'top_reader_badge', Organization = 'organization', Brief = 'brief', + Digest = 'digest', Achievement = 'achievement', } diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 5c55de0b0e..2b2b07857e 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'; + index: number; + title: string; + link: string; + image: string; + companyName: string; + companyLogo: string; + callToAction: 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/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 380bfb5e69..8ab0a10f77 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 { @@ -390,6 +391,18 @@ export default function useFeed( }), ); } + + if (settings?.staticAd && feedQuery.data && 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, @@ -400,6 +413,7 @@ export default function useFeed( placeholdersPerPage, getAd, settings.plusEntry, + settings.staticAd, ]); const updatePost = updateCachedPagePost(feedQueryKey, queryClient); 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; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cb052566e6..b4baaa02fa 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,4 @@ export type ErrorBoundaryFeature = | 'extension-feed' | 'onboarding' | '404-page'; + 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