diff --git a/AGENTS.md b/AGENTS.md index 9c5bc0b8969..38c1d85a9d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,6 +406,8 @@ const handleClick = useCallback((key: string) => { Keep PR descriptions concise and to the point. Reviewers should not be exhausted by lengthy explanations. +Use conventional commit messages for all commits, for example `fix: ...`, `feat: ...`, or `chore: ...`. + Before opening a PR, run `git diff --name-only origin/main...HEAD` and confirm every changed file belongs to the current task. If unrelated files appear (for example from reverted or merged commits), clean the branch history first. ## Code Review Guidelines diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index e20707e1d55..24c4eb3b4f9 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -29,7 +29,12 @@ import { useLogContext } from '../contexts/LogContext'; import { feedLogExtra, postLogEvent } from '../lib/feed'; import { usePostModalNavigation } from '../hooks/usePostModalNavigation'; import { useSharePost } from '../hooks/useSharePost'; -import { LogEvent, Origin, TargetId } from '../lib/log'; +import { + LogEvent, + NotificationCtaPlacement, + Origin, + TargetId, +} from '../lib/log'; import { SharedFeedPage } from './utilities'; import type { FeedContainerProps } from './feeds/FeedContainer'; import { FeedContainer } from './feeds/FeedContainer'; @@ -60,6 +65,7 @@ import { FeedCardContext } from '../features/posts/FeedCardContext'; import { briefCardFeedFeature, briefFeedEntrypointPage, + featureFeedAdTemplate, featureFeedLayoutV2, } from '../lib/featureManagement'; import type { AwardProps } from '../graphql/njord'; @@ -67,6 +73,8 @@ import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; +import { TopHero } from './banners/HeroBottomBanner'; +import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; const FeedErrorScreen = dynamic( () => import(/* webpackChunkName: "feedErrorScreen" */ './FeedErrorScreen'), @@ -251,6 +259,8 @@ export default function Feed({ }); const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue; const [getProducts] = useUpdateQuery(getProductsQueryOptions()); + const adTemplate = currentSettings.adTemplate ?? + featureFeedAdTemplate.defaultValue?.default ?? { adStart: 1 }; const { value: briefBannerPage } = useConditionalFeature({ feature: briefFeedEntrypointPage, @@ -272,10 +282,10 @@ export default function Feed({ pageSize ?? currentSettings.pageSize, isSquadFeed || shouldUseListFeedLayout ? { - ...currentSettings.adTemplate, + ...adTemplate, adStart: 2, // always make adStart 2 for squads due to welcome and pinned posts } - : currentSettings.adTemplate, + : adTemplate, numCards, { onEmptyFeed, @@ -294,7 +304,9 @@ export default function Feed({ }, ); const canFetchMore = allowFetchMore ?? queryCanFetchMore; - const [postModalIndex, setPostModalIndex] = useState(null); + const [postModalIndex, setPostModalIndex] = useState( + null, + ); const { onMenuClick, postMenuIndex, postMenuLocation } = useFeedContextMenu(); const useList = isListMode && numCards > 1; const virtualizedNumCards = useList ? 1 : numCards; @@ -313,6 +325,19 @@ export default function Feed({ canFetchMore, feedName, }); + const { + heroInsertIndex, + shouldShowTopHero, + shouldShowInFeedHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + shouldShowDismiss: shouldShowReadingReminderDismiss, + onEnableHero, + onDismissHero, + } = useReadingReminderFeedHero({ + itemCount: items.length, + itemsPerRow: virtualizedNumCards, + }); useMutationSubscription({ matcher: ({ mutation }) => { @@ -326,19 +351,24 @@ export default function Feed({ if (type === 'POST') { const postItem = items.find( - (item: PostItem) => item.post.id === entityId && item.type === 'post', - ) as PostItem; - - const currentPost = postItem?.post; + (item): item is PostItem => + item.type === 'post' && item.post.id === entityId, + ); - if (!!currentPost && entityId !== currentPost.id) { + if (!postItem) { return; } + const currentPost = postItem.post; + const awardProduct = getProducts()?.edges.find( (item) => item.node.id === productId, )?.node; + if (!currentPost.userState || awardProduct?.value === undefined) { + return; + } + updatePost(postItem.page, postItem.index, { ...currentPost, userState: { @@ -359,13 +389,14 @@ export default function Feed({ }); const logOpts = useMemo(() => { + const modalRow = postModalIndex?.row; + const modalColumn = postModalIndex?.column; + return { columns: virtualizedNumCards, - row: !isNullOrUndefined(postModalIndex?.row) - ? postModalIndex.row - : postMenuLocation?.row, - column: !isNullOrUndefined(postModalIndex?.column) - ? postModalIndex.column + row: !isNullOrUndefined(modalRow) ? modalRow : postMenuLocation?.row, + column: !isNullOrUndefined(modalColumn) + ? modalColumn : postMenuLocation?.column, is_ad: selectedPostIsAd ? true : undefined, }; @@ -399,7 +430,7 @@ export default function Feed({ const { toggleUpvote, toggleDownvote } = useFeedVotePost({ feedName, - ranking, + ranking: ranking ?? '', items, updatePost, feedQueryKey, @@ -408,7 +439,7 @@ export default function Feed({ const { toggleBookmark } = useFeedBookmarkPost({ feedName, feedQueryKey, - ranking, + ranking: ranking ?? '', items, updatePost, }); @@ -541,7 +572,7 @@ export default function Feed({ } }; - const PostModal = PostModalMap[selectedPost?.type]; + const PostModal = selectedPost ? PostModalMap[selectedPost.type] : undefined; if (isError) { return ; @@ -555,12 +586,31 @@ export default function Feed({ feedName as SharedFeedPage, ); + const currentPageSize = pageSize ?? currentSettings.pageSize; + const showPromoBanner = !!briefBannerPage; + const columnsDiffWithPage = currentPageSize % virtualizedNumCards; + const showFirstSlotCard = showProfileCompletionCard || showBriefCard; + const indexWhenShowingPromoBanner = + currentPageSize * Number(briefBannerPage) - // number of items at that page + columnsDiffWithPage * Number(briefBannerPage) - // cards let out of rows * page number + Number(showFirstSlotCard); + const FeedWrapperComponent = isSearchPageLaptop ? SearchResultsLayout : FeedContainer; const containerProps = isSearchPageLaptop ? {} : { + topContent: shouldShowTopHero ? ( + onEnableHero(NotificationCtaPlacement.TopHero)} + onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} + /> + ) : undefined, header, inlineHeader, className, @@ -574,15 +624,6 @@ export default function Feed({ disableListWidthConstraint, }; - const currentPageSize = pageSize ?? currentSettings.pageSize; - const showPromoBanner = !!briefBannerPage; - const columnsDiffWithPage = currentPageSize % virtualizedNumCards; - const showFirstSlotCard = showProfileCompletionCard || showBriefCard; - const indexWhenShowingPromoBanner = - currentPageSize * Number(briefBannerPage) - // number of items at that page - columnsDiffWithPage * Number(briefBannerPage) - // cards let out of rows * page number - Number(showFirstSlotCard); - return ( @@ -609,20 +650,42 @@ export default function Feed({ {showPromoBanner && index === indexWhenShowingPromoBanner && ( )} + {shouldShowInFeedHero && index === heroInsertIndex && ( +
+ + onEnableHero(NotificationCtaPlacement.InFeedHero) + } + onClose={() => + onDismissHero(NotificationCtaPlacement.InFeedHero) + } + /> +
+ )} @@ -106,10 +107,13 @@ type FeedQueryProps = { query: string; queryIfLogged?: string; variables?: Record; - requestKey?: string; + requestKey?: RequestKey | SharedFeedPage | OtherFeedPage; + emptyScreen?: ReactNode; }; -const propsByFeed: Record = { +type FeedConfigPage = SharedFeedPage | OtherFeedPage; + +const propsByFeed: Partial> = { 'my-feed': { query: ANONYMOUS_FEED_QUERY, queryIfLogged: FEED_QUERY, @@ -227,6 +231,12 @@ export default function MainFeedLayout({ onEnable, onDismiss, } = useReadingReminderHero(); + const isHomePage = router.pathname === webappUrl; + const shouldEvaluateReminderPlacement = + isHomePage && shouldShowReadingReminder; + const { isControl: isControlVariation } = useReadingReminderVariation({ + shouldEvaluate: shouldEvaluateReminderPlacement, + }); const { isUpvoted, isPopular, @@ -309,9 +319,7 @@ export default function MainFeedLayout({ /** * Various feeds can have different feed versions based on feature flag */ - const dynamicFeedVersionByFeed: Partial< - Record - > = { + const dynamicFeedVersionByFeed: Partial> = { [SharedFeedPage.MyFeed]: myFeedV, [OtherFeedPage.Following]: followingFeedV, [OtherFeedPage.Explore]: exploreFeedV, @@ -321,25 +329,30 @@ export default function MainFeedLayout({ [SharedFeedPage.Custom]: customFeedV, }; + const feedConfig = propsByFeed[feedName]; + const dynamicFeedConfig = + feedName in dynamicPropsByFeed + ? dynamicPropsByFeed[feedName as SharedFeedPage] + : undefined; + // do not show feed in background on new page - if (router.pathname === '/feeds/new') { + if (router.pathname === '/feeds/new' || !feedConfig) { return { query: null, }; } return { - requestKey: propsByFeed[feedName].requestKey, + requestKey: feedConfig.requestKey, query: getQueryBasedOnLogin( tokenRefreshed, - user, - dynamicPropsByFeed[feedName]?.query || propsByFeed[feedName].query, - dynamicPropsByFeed[feedName]?.queryIfLogged || - propsByFeed[feedName].queryIfLogged, + user ?? null, + dynamicFeedConfig?.query || feedConfig.query, + dynamicFeedConfig?.queryIfLogged || feedConfig.queryIfLogged || null, ), variables: { - ...propsByFeed[feedName].variables, - ...dynamicPropsByFeed[feedName]?.variables, + ...feedConfig.variables, + ...dynamicFeedConfig?.variables, version: isDevelopment && !isProductionAPI ? 1 @@ -378,7 +391,9 @@ export default function MainFeedLayout({ const search = useMemo( () => hasSearchContent ? ( - + {navChildren} {isSearchOn && searchChildren ? searchChildren : undefined} @@ -386,7 +401,16 @@ export default function MainFeedLayout({ [hasSearchContent, isSearchOn, isSearchPage, navChildren, searchChildren], ); - const feedProps = useMemo>(() => { + const handleSelectedAlgoChange = useCallback( + (value: SetStateAction) => { + const nextValue = + typeof value === 'function' ? value(selectedAlgo) : value; + setSelectedAlgo(nextValue).catch(() => undefined); + }, + [selectedAlgo, setSelectedAlgo], + ); + + const feedProps = useMemo | null>(() => { const feedWithActions = isUpvoted || isPopular || isSortableFeed || isCustomFeed; // in list search by default we do not show any results but empty state @@ -402,6 +426,10 @@ export default function MainFeedLayout({ } if (feedNameProp === 'default' && isCustomDefaultFeed) { + if (!defaultFeedId) { + return null; + } + return { feedName: SharedFeedPage.Custom, feedQueryKey: generateQueryKey( @@ -411,13 +439,13 @@ export default function MainFeedLayout({ ), query: CUSTOM_FEED_QUERY, variables: { - feedId: user.defaultFeedId, + feedId: defaultFeedId, feedName: SharedFeedPage.Custom, }, - emptyScreen: propsByFeed[feedName].emptyScreen || , + emptyScreen: propsByFeed[feedName]?.emptyScreen || , actionButtons: feedWithActions && ( ), @@ -493,10 +521,10 @@ export default function MainFeedLayout({ ), query: config.query, variables, - emptyScreen: propsByFeed[feedName].emptyScreen || , + emptyScreen: propsByFeed[feedName]?.emptyScreen || , actionButtons: feedWithActions && ( ), @@ -516,7 +544,7 @@ export default function MainFeedLayout({ feedName, user, selectedAlgo, - setSelectedAlgo, + handleSelectedAlgoChange, defaultFeedId, getFeatureValue, contentCurationFilter, @@ -541,7 +569,7 @@ export default function MainFeedLayout({ const disableTopPadding = isFinder || shouldUseListFeedLayout; const shouldShowReadingReminderOnHomepage = - router.pathname === webappUrl && shouldShowReadingReminder; + shouldEvaluateReminderPlacement && isControlVariation; const onTabChange = useCallback( (clickedTab: ExploreTabs) => { @@ -603,7 +631,7 @@ export default function MainFeedLayout({ {shouldUseCommentFeedLayout ? ( void; + onClose?: () => void; +}; + +export const TopHero = ({ + className, + title = 'Never miss a learning day', + subtitle = 'Turn on your daily reading reminder and keep your routine.', + shouldShowDismiss = false, + onCtaClick, + onClose, +}: TopHeroProps): ReactElement => { + return ( +
+
+
+
+
+
+
+ {shouldShowDismiss && ( + +
+
+
+ +
+
+
+
+
+ ); +}; diff --git a/packages/shared/src/components/banners/ReadingReminderCatLaptop.tsx b/packages/shared/src/components/banners/ReadingReminderCatLaptop.tsx new file mode 100644 index 00000000000..789c09aa223 --- /dev/null +++ b/packages/shared/src/components/banners/ReadingReminderCatLaptop.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +interface ReadingReminderCatLaptopProps { + className?: string; +} + +const ReadingReminderCatLaptop = ({ + className, +}: ReadingReminderCatLaptopProps): ReactElement => { + return ( + Sleeping cat on laptop + ); +}; + +export default ReadingReminderCatLaptop; diff --git a/packages/shared/src/components/banners/ReadingReminderHero.spec.tsx b/packages/shared/src/components/banners/ReadingReminderHero.spec.tsx index 239c3c7eb7c..f7b671dc638 100644 --- a/packages/shared/src/components/banners/ReadingReminderHero.spec.tsx +++ b/packages/shared/src/components/banners/ReadingReminderHero.spec.tsx @@ -45,9 +45,7 @@ describe('ReadingReminderHero', () => { fireEvent.click(screen.getByRole('button', { name: 'Enable reminder' })); expect(onEnable).toHaveBeenCalledTimes(1); - expect( - screen.queryByRole('button', { name: 'Close' }), - ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Close')).not.toBeInTheDocument(); }); it('should handle dismiss action when enabled', () => { diff --git a/packages/shared/src/components/cards/entity/EnableNotificationsCta.tsx b/packages/shared/src/components/cards/entity/EnableNotificationsCta.tsx new file mode 100644 index 00000000000..724b54f3c8c --- /dev/null +++ b/packages/shared/src/components/cards/entity/EnableNotificationsCta.tsx @@ -0,0 +1,75 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { BellIcon } from '../../icons'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { NotificationCtaKind } from '../../../lib/log'; +import type { + NotificationCtaPlacement, + NotificationPromptSource, +} from '../../../lib/log'; +import { + useNotificationCtaAnalytics, + useNotificationCtaImpression, +} from '../../../hooks/notifications/useNotificationCtaAnalytics'; + +type EnableNotificationsCtaProps = { + onEnable: () => void | Promise; + message?: string; + analytics: { + placement: NotificationCtaPlacement; + targetType: string; + source?: NotificationPromptSource; + targetId?: string; + }; +}; + +const EnableNotificationsCta = ({ + onEnable, + message = 'Get notified about new posts', + analytics, +}: EnableNotificationsCtaProps): ReactElement => { + const { logClick } = useNotificationCtaAnalytics(); + + useNotificationCtaImpression({ + kind: NotificationCtaKind.FollowUpCta, + ...analytics, + }); + + const onEnableClick = useCallback(async () => { + logClick({ + kind: NotificationCtaKind.FollowUpCta, + ...analytics, + }); + await onEnable(); + }, [analytics, logClick, onEnable]); + + return ( +
+ + {message} + + +
+ ); +}; + +export default EnableNotificationsCta; diff --git a/packages/shared/src/components/cards/entity/SourceEntityCard.tsx b/packages/shared/src/components/cards/entity/SourceEntityCard.tsx index 9d2ec604255..4853a760d8e 100644 --- a/packages/shared/src/components/cards/entity/SourceEntityCard.tsx +++ b/packages/shared/src/components/cards/entity/SourceEntityCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Link from '../../utilities/Link'; import EntityCard from './EntityCard'; import { @@ -13,11 +13,23 @@ import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu'; import { ButtonVariant } from '../../buttons/Button'; import { Separator } from '../common/common'; import EntityDescription from './EntityDescription'; +import EnableNotificationsCta from './EnableNotificationsCta'; import useSourceMenuProps from '../../../hooks/useSourceMenuProps'; -import { ContentPreferenceType } from '../../../graphql/contentPreference'; +import { + ContentPreferenceStatus, + ContentPreferenceType, +} from '../../../graphql/contentPreference'; +import { + NotificationCtaPlacement, + NotificationPromptSource, + TargetType, +} from '../../../lib/log'; import useShowFollowAction from '../../../hooks/useShowFollowAction'; import { FollowButton } from '../../contentPreference/FollowButton'; import { useContentPreferenceStatusQuery } from '../../../hooks/contentPreference/useContentPreferenceStatusQuery'; +import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; +import { useSourceActionsNotify } from '../../../hooks/source/useSourceActionsNotify'; +import { useNotificationCtaExperiment } from '../../../hooks/notifications/useNotificationCtaExperiment'; type SourceEntityCardProps = { source: SourceTooltip; @@ -27,29 +39,88 @@ type SourceEntityCardProps = { }; const SourceEntityCard = ({ source, className }: SourceEntityCardProps) => { + const sourceId = source?.id ?? ''; const { showActionBtn } = useShowFollowAction({ - entityId: source?.id, + entityId: sourceId, entityType: ContentPreferenceType.Source, }); const { data: contentPreference } = useContentPreferenceStatusQuery({ - id: source?.id, + id: sourceId, entity: ContentPreferenceType.Source, }); + const [showNotificationCta, setShowNotificationCta] = useState(false); + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment({ + shouldEvaluate: showNotificationCta, + }); + const { subscribe } = useContentPreference(); + const prevStatusRef = useRef(contentPreference?.status); const menuProps = useSourceMenuProps({ source }); + const { haveNotificationsOn, onNotify } = useSourceActionsNotify({ + source, + }); + + const currentStatus = contentPreference?.status; + const isNowFollowing = + currentStatus === ContentPreferenceStatus.Follow || + currentStatus === ContentPreferenceStatus.Subscribed; + const wasFollowing = + prevStatusRef.current === ContentPreferenceStatus.Follow || + prevStatusRef.current === ContentPreferenceStatus.Subscribed; + const shouldRenderNotificationCta = + isNotificationCtaExperimentEnabled && + showNotificationCta && + !haveNotificationsOn; + + useEffect(() => { + if (currentStatus === prevStatusRef.current) { + return; + } + + prevStatusRef.current = currentStatus; + + if (isNowFollowing && !wasFollowing) { + setShowNotificationCta(true); + return; + } + + if (!isNowFollowing && wasFollowing) { + setShowNotificationCta(false); + } + }, [currentStatus, isNowFollowing, wasFollowing]); + + if (!source?.id || !source.name || !source.image || !source.permalink) { + return null; + } + + const handleTurnOn = async () => { + if (!source?.id) { + throw new Error('Cannot subscribe to notifications without source id'); + } + + if (currentStatus !== ContentPreferenceStatus.Subscribed) { + await subscribe({ + id: source.id, + entity: ContentPreferenceType.Source, + entityName: source.name ?? source.id, + }); + } + + await onNotify(); + setShowNotificationCta(false); + }; - const { description, membersCount, flags, name, image, permalink } = - source || {}; return ( { } >
- + { color={TypographyColor.Primary} bold > - {name} + {source.name} - {description && } + {source.description && ( + + )}
- {largeNumberFormat(membersCount ?? 0) || 0} Followers + {largeNumberFormat(source.membersCount ?? 0) || 0} Followers - {largeNumberFormat(flags?.totalUpvotes ?? 0) || 0} Upvotes + {largeNumberFormat(source.flags?.totalUpvotes ?? 0) || 0} Upvotes
+ {shouldRenderNotificationCta && ( + + )}
); diff --git a/packages/shared/src/components/cards/entity/SquadEntityCard.tsx b/packages/shared/src/components/cards/entity/SquadEntityCard.tsx index 9978af817fc..d8889f3cf35 100644 --- a/packages/shared/src/components/cards/entity/SquadEntityCard.tsx +++ b/packages/shared/src/components/cards/entity/SquadEntityCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Link from '../../utilities/Link'; import { Typography, @@ -7,6 +7,11 @@ import { TypographyType, } from '../../typography/Typography'; import type { Origin } from '../../../lib/log'; +import { + NotificationCtaPlacement, + NotificationPromptSource, + TargetType, +} from '../../../lib/log'; import { largeNumberFormat } from '../../../lib'; import { SquadActionButton } from '../../squads/SquadActionButton'; import { SourceIcon } from '../../icons'; @@ -19,10 +24,14 @@ import EntityDescription from './EntityDescription'; import EntityCard from './EntityCard'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; import useShowFollowAction from '../../../hooks/useShowFollowAction'; +import EnableNotificationsCta from './EnableNotificationsCta'; +import { useSourceActionsNotify } from '../../../hooks/source/useSourceActionsNotify'; +import { useNotificationCtaExperiment } from '../../../hooks/notifications/useNotificationCtaExperiment'; type SquadEntityCardProps = { handle: string; origin: Origin; + showNotificationCtaOnJoin?: boolean; className?: { container?: string; }; @@ -31,15 +40,51 @@ type SquadEntityCardProps = { const SquadEntityCard = ({ handle, origin, + showNotificationCtaOnJoin = false, className, }: SquadEntityCardProps) => { const { squad } = useSquad({ handle }); + const [showNotificationCta, setShowNotificationCta] = useState(false); + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment({ + shouldEvaluate: showNotificationCta, + }); + const wasSquadMemberRef = useRef(!!squad?.currentMember); const { isLoading } = useShowFollowAction({ - entityId: squad?.id, + entityId: squad?.id ?? '', entityType: ContentPreferenceType.Source, }); + const { haveNotificationsOn, onNotify } = useSourceActionsNotify({ + source: squad, + }); + + const isSquadMember = !!squad?.currentMember; + const shouldRenderNotificationCta = + isNotificationCtaExperimentEnabled && + showNotificationCta && + !haveNotificationsOn; + + useEffect(() => { + if ( + showNotificationCtaOnJoin && + isSquadMember && + !wasSquadMemberRef.current && + !haveNotificationsOn + ) { + setShowNotificationCta(true); + } else if (!isSquadMember) { + setShowNotificationCta(false); + } + + wasSquadMemberRef.current = isSquadMember; + }, [haveNotificationsOn, isSquadMember, showNotificationCtaOnJoin]); + + const handleEnableNotifications = async () => { + await onNotify(); + setShowNotificationCta(false); + }; - if (!squad) { + if (!squad?.id || !squad.name || !squad.image || !squad.permalink) { return null; } @@ -107,16 +152,27 @@ const SquadEntityCard = ({ type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {largeNumberFormat(membersCount)} Members + {largeNumberFormat(membersCount ?? 0)} Members - {largeNumberFormat(flags?.totalUpvotes)} Upvotes + {largeNumberFormat(flags?.totalUpvotes ?? 0)} Upvotes + {shouldRenderNotificationCta && ( + + )} ); diff --git a/packages/shared/src/components/cards/entity/UserEntityCard.tsx b/packages/shared/src/components/cards/entity/UserEntityCard.tsx index 2d18d84b4f1..fe3f95e2196 100644 --- a/packages/shared/src/components/cards/entity/UserEntityCard.tsx +++ b/packages/shared/src/components/cards/entity/UserEntityCard.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import Link from '../../utilities/Link'; import type { UserShortProfile } from '../../../lib/user'; import EntityCard from './EntityCard'; @@ -24,7 +24,7 @@ import { useContentPreference } from '../../../hooks/contentPreference/useConten import { LazyModal } from '../../modals/common/types'; import { useLazyModal } from '../../../hooks/useLazyModal'; import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; -import { LogEvent, TargetId } from '../../../lib/log'; +import { LogEvent, NotificationCtaPlacement, TargetId } from '../../../lib/log'; import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu'; import AuthContext from '../../../contexts/AuthContext'; import { ButtonVariant } from '../../buttons/Button'; @@ -32,39 +32,57 @@ import EntityDescription from './EntityDescription'; import useUserMenuProps from '../../../hooks/useUserMenuProps'; import useShowFollowAction from '../../../hooks/useShowFollowAction'; import type { MenuItemProps } from '../../dropdown/common'; +import EnableNotificationsCta from './EnableNotificationsCta'; +import { useNotificationCtaExperiment } from '../../../hooks/notifications/useNotificationCtaExperiment'; type Props = { user?: UserShortProfile; + showNotificationCtaOnFollow?: boolean; className?: { container?: string; }; }; -const UserEntityCard = ({ user, className }: Props) => { +const UserEntityCard = ({ + user, + className, + showNotificationCtaOnFollow = false, +}: Props) => { const { user: loggedUser } = useContext(AuthContext); const isSameUser = loggedUser?.id === user?.id; + const userId = user?.id ?? ''; + const [showNotificationCta, setShowNotificationCta] = useState(false); const { data: contentPreference } = useContentPreferenceStatusQuery({ - id: user?.id, + id: userId, entity: ContentPreferenceType.User, }); - const { unblock, block } = useContentPreference(); + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment({ + shouldEvaluate: showNotificationCta, + }); + const { unblock, block, subscribe } = useContentPreference(); + const prevStatusRef = useRef(contentPreference?.status); const blocked = contentPreference?.status === ContentPreferenceStatus.Blocked; const { openModal } = useLazyModal(); const { logSubscriptionEvent } = usePlusSubscription(); const menuProps = useUserMenuProps({ user }); const { isLoading } = useShowFollowAction({ - entityId: user?.id, + entityId: userId, entityType: ContentPreferenceType.User, }); const onReportUser = React.useCallback( (defaultBlocked = false) => { + if (!user?.id || !user.username) { + return; + } + openModal({ type: LazyModal.ReportUser, props: { offendingUser: { - id: user?.id, - username: user?.username, + id: user.id, + username: user.username, }, defaultBlockUser: defaultBlocked, }, @@ -74,6 +92,44 @@ const UserEntityCard = ({ user, className }: Props) => { ); const { username, bio, name, image, isPlus, createdAt, id, permalink } = user || {}; + + const currentStatus = contentPreference?.status; + const isNowFollowing = + currentStatus === ContentPreferenceStatus.Follow || + currentStatus === ContentPreferenceStatus.Subscribed; + const haveNotificationsOn = + currentStatus === ContentPreferenceStatus.Subscribed; + const shouldRenderNotificationCta = + isNotificationCtaExperimentEnabled && + showNotificationCta && + !haveNotificationsOn; + + useEffect(() => { + const previousStatus = prevStatusRef.current; + + if (previousStatus === currentStatus) { + return; + } + + const wasFollowing = + previousStatus === ContentPreferenceStatus.Follow || + previousStatus === ContentPreferenceStatus.Subscribed; + + if (showNotificationCtaOnFollow && isNowFollowing && !wasFollowing) { + setShowNotificationCta(true); + } else if (!isNowFollowing && wasFollowing) { + setShowNotificationCta(false); + } + + prevStatusRef.current = currentStatus; + }, [currentStatus, isNowFollowing, showNotificationCtaOnFollow]); + + const showActionBtns = !!user && !isLoading && !isSameUser; + + if (!user || !id || !username || !image || !permalink || !createdAt) { + return null; + } + const options: MenuItemProps[] = [ { icon: , @@ -115,11 +171,18 @@ const UserEntityCard = ({ user, className }: Props) => { }); } - const showActionBtns = !!user && !isLoading && !isSameUser; + const handleEnableNotifications = async () => { + if (!id || !username) { + throw new Error('Cannot subscribe to notifications without user id'); + } - if (!user) { - return null; - } + await subscribe({ + id, + entity: ContentPreferenceType.User, + entityName: username, + }); + setShowNotificationCta(false); + }; return ( { /> {bio && } + {shouldRenderNotificationCta && ( + + )} ); diff --git a/packages/shared/src/components/comments/MainComment.tsx b/packages/shared/src/components/comments/MainComment.tsx index b934ae04afe..6c084772fe6 100644 --- a/packages/shared/src/components/comments/MainComment.tsx +++ b/packages/shared/src/components/comments/MainComment.tsx @@ -9,7 +9,12 @@ import CommentBox from './CommentBox'; import SubComment from './SubComment'; import CollapsedRepliesPreview from './CollapsedRepliesPreview'; import AuthContext from '../../contexts/AuthContext'; -import { LogEvent, NotificationPromptSource, TargetType } from '../../lib/log'; +import { + LogEvent, + NotificationCtaPlacement, + NotificationPromptSource, + TargetType, +} from '../../lib/log'; import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; import { useComments } from '../../hooks/post'; import { SquadCommentJoinBanner } from '../squads/SquadCommentJoinBanner'; @@ -47,11 +52,13 @@ export interface MainCommentProps } const shouldShowBannerOnComment = ( - commentId: string, + commentId: string | undefined, comment: Comment, ): boolean => - commentId === comment.id || - (comment.children?.edges?.some(({ node }) => node.id === commentId) ?? false); + !!commentId && + (commentId === comment.id || + (comment.children?.edges?.some(({ node }) => node.id === commentId) ?? + false)); export default function MainComment({ className, @@ -72,7 +79,6 @@ export default function MainComment({ () => shouldShowBannerOnComment(permissionNotificationCommentId, comment), [permissionNotificationCommentId, comment], ); - const [isJoinSquadBannerDismissed] = usePersistentContext( SQUAD_COMMENT_JOIN_BANNER_KEY, false, @@ -91,8 +97,8 @@ export default function MainComment({ onReplyTo, } = useComments(props.post); const { inputProps: editProps, onEdit } = useEditCommentProps(); - - const replyCount = comment.children?.edges?.length ?? 0; + const commentChildren = comment.children?.edges ?? []; + const replyCount = commentChildren.length; const initialInView = !lazy; const { ref: inViewRef, inView } = useInView({ @@ -145,9 +151,7 @@ export default function MainComment({ parentId={comment.id} className={{ container: classNames( - comment.children?.edges?.length > 0 && - !isModalThread && - 'border-b', + commentChildren.length > 0 && !isModalThread && 'border-b', isModalThread && 'rounded-none border-0 bg-transparent px-0 pb-0 pt-0 hover:bg-transparent', ), @@ -161,7 +165,7 @@ export default function MainComment({ appendTooltipTo={appendTooltipTo} onComment={(selected, parentId) => onReplyTo({ - username: selected.author.username, + username: selected.author?.username ?? null, parentCommentId: parentId, commentId: selected.id, }) @@ -197,7 +201,7 @@ export default function MainComment({ post={props.post} onCommented={(...params) => { onEdit(null); - onCommented(...params); + onCommented?.(...params); }} onClose={() => onEdit(null)} className={{ input: className?.commentBox }} @@ -210,7 +214,7 @@ export default function MainComment({ post={props.post} onCommented={(...params) => { onReplyTo(null); - onCommented(...params); + onCommented?.(...params); }} onClose={() => onReplyTo(null)} className={{ input: className?.commentBox }} @@ -218,9 +222,27 @@ export default function MainComment({ /> )} + {showJoinSquadBanner && ( + + )} + {!showJoinSquadBanner && showNotificationPermissionBanner && ( + + )} {inView && replyCount > 0 && !areRepliesExpanded && ( setAreRepliesExpanded(true)} isThreadStyle={isModalThread} className={isModalThread ? 'ml-12 mt-3' : undefined} @@ -244,7 +266,7 @@ export default function MainComment({ Hide replies )} - {comment.children?.edges.map(({ node }, index) => ( + {commentChildren.map(({ node }, index) => ( ))} )} - {showJoinSquadBanner && ( - - )} - {!showJoinSquadBanner && showNotificationPermissionBanner && ( - - )} ); } diff --git a/packages/shared/src/components/errors/SquadLoading.tsx b/packages/shared/src/components/errors/SquadLoading.tsx index 8f558573020..01e32ff94bc 100644 --- a/packages/shared/src/components/errors/SquadLoading.tsx +++ b/packages/shared/src/components/errors/SquadLoading.tsx @@ -36,7 +36,7 @@ function SquadLoading({ squad, sidebarRendered, }: { - squad: Pick; + squad?: Pick; sidebarRendered: boolean; }): ReactElement { return ( diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index 424773f6a65..d15174e127c 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -36,6 +36,7 @@ import { TargetId } from '../../lib/log'; export interface FeedContainerProps { children: ReactNode; + topContent?: ReactNode; header?: ReactNode; footer?: ReactNode; className?: string; @@ -50,17 +51,19 @@ export interface FeedContainerProps { disableListWidthConstraint?: boolean; } -const listGaps = { +const listGaps: Record = { + eco: 'gap-2', cozy: 'gap-5', roomy: 'gap-3', }; -const gridGaps = { +const gridGaps: Record = { + eco: 'gap-8', cozy: 'gap-14', roomy: 'gap-12', }; -const cardListClass = { +const cardListClass: Partial> = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3', @@ -80,6 +83,8 @@ export const getFeedGapPx = { 'gap-14': 56, }; +type FeedGapClass = keyof typeof getFeedGapPx; + /** * Returns the appropriate gap class based on layout mode and spaciness. * @param defaultGridGap - Optional override for grid gap (used by feature flags like feed_layout_v2) @@ -116,7 +121,7 @@ const cardClass = ({ if (isHorizontal) { return 'auto-cols-[calc((100%/var(--num-cards))-var(--feed-gap)-calc(var(--feed-gap)*+1)/var(--num-cards))] tablet:auto-cols-[calc((100%/var(--num-cards))-var(--feed-gap)-calc(var(--feed-gap)*-1)/var(--num-cards))]'; } - return isList ? 'grid-cols-1' : cardListClass[numberOfCards]; + return isList ? 'grid-cols-1' : cardListClass[numberOfCards] ?? 'grid-cols-1'; }; const getStyle = (isList: boolean, space: Spaciness): CSSProperties => { @@ -150,6 +155,7 @@ const feedNameToHeading: Record< export const FeedContainer = ({ children, + topContent, header, footer, className, @@ -172,8 +178,9 @@ export const FeedContainer = ({ const { shouldUseListFeedLayout, isListMode } = useFeedLayout(); const isLaptop = useViewSize(ViewSize.Laptop); const { feedName } = useActiveFeedNameContext(); + const activeFeedName = feedName ?? SharedFeedPage.MyFeed; const { isAnyExplore, isExplorePopular, isExploreLatest } = useFeedName({ - feedName, + feedName: activeFeedName, }); const router = useRouter(); const effectiveSpaciness: Spaciness = isFeedLayoutV2 @@ -185,15 +192,13 @@ export const FeedContainer = ({ ? false : (isListMode && numCards > 1) || shouldUseListFeedLayout; const v2GridGap = isFeedLayoutV2 ? 'gap-4' : undefined; - const feedGapPx = - getFeedGapPx[ - gapClass({ - isList, - isFeedLayoutList: shouldUseListFeedLayout, - space: effectiveSpaciness, - defaultGridGap: v2GridGap, - }) - ]; + const feedGapClass = gapClass({ + isList, + isFeedLayoutList: shouldUseListFeedLayout, + space: effectiveSpaciness, + defaultGridGap: v2GridGap, + }) as FeedGapClass; + const feedGapPx = getFeedGapPx[feedGapClass]; const style = { '--num-cards': isHorizontal && isListMode && numCards >= 2 ? 2 : numCards, '--feed-gap': `${feedGapPx / 16}rem`, @@ -207,7 +212,7 @@ export const FeedContainer = ({ const { feeds } = useFeeds(); const feedHeading = useMemo(() => { - if (feedName === SharedFeedPage.Custom) { + if (activeFeedName === SharedFeedPage.Custom) { const customFeed = feeds?.edges.find( ({ node: feed }) => feed.id === router.query.slugOrId || @@ -219,16 +224,26 @@ export const FeedContainer = ({ } } - return feedNameToHeading[feedName] ?? ''; - }, [feeds, feedName, router.query.slugOrId]); + if (activeFeedName in feedNameToHeading) { + return feedNameToHeading[ + activeFeedName as keyof typeof feedNameToHeading + ]; + } + + return ''; + }, [activeFeedName, feeds, router.query.slugOrId]); const { getMarketingCta, clearMarketingCta } = useBoot(); const marketingCta = getMarketingCta(MarketingCtaVariant.FeedBanner); const { onUpload, status, shouldShow } = useUploadCv({ - onUploadSuccess: () => clearMarketingCta(marketingCta.campaignId), + onUploadSuccess: () => { + if (marketingCta) { + clearMarketingCta(marketingCta.campaignId); + } + }, }); const shouldShowBanner = - !!marketingCta && shouldShow && feedName === SharedFeedPage.MyFeed; + !!marketingCta && shouldShow && activeFeedName === SharedFeedPage.MyFeed; const clearMarketingCtaRef = useRef(clearMarketingCta); clearMarketingCtaRef.current = clearMarketingCta; @@ -265,21 +280,28 @@ export const FeedContainer = ({ 'mb-0': isList, 'mt-0 tablet:mt-4': !showBriefCard, }), - image: - isList && 'laptop:bottom-0 laptop:right-0 laptop:top-[unset]', + image: isList + ? 'laptop:bottom-0 laptop:right-0 laptop:top-[unset]' + : undefined, }} status={status} onUpload={onUpload} - onClose={() => clearMarketingCta(marketingCta.campaignId)} - banner={{ - title: marketingCta?.flags?.title, - description: marketingCta?.flags?.description, - cover: { - laptop: isList ? uploadCvBgTablet : uploadCvBgLaptop, - tablet: uploadCvBgTablet, - base: uploadCvBgMobile, - }, - }} + onClose={() => + marketingCta && clearMarketingCta(marketingCta.campaignId) + } + banner={ + marketingCta?.flags?.title && marketingCta?.flags?.description + ? { + title: marketingCta.flags.title, + description: marketingCta.flags.description, + cover: { + laptop: isList ? uploadCvBgTablet : uploadCvBgLaptop, + tablet: uploadCvBgTablet, + base: uploadCvBgMobile, + }, + } + : undefined + } targetId={TargetId.Feed} /> @@ -297,6 +319,7 @@ export const FeedContainer = ({ data-testid="posts-feed" > {inlineHeader && header} + {topContent} {isSearch && !shouldUseListFeedLayout && ( ( {feedHeading} diff --git a/packages/shared/src/components/modals/post/BookmarkReminderModal.tsx b/packages/shared/src/components/modals/post/BookmarkReminderModal.tsx index 77df1cc8965..062499b4ee7 100644 --- a/packages/shared/src/components/modals/post/BookmarkReminderModal.tsx +++ b/packages/shared/src/components/modals/post/BookmarkReminderModal.tsx @@ -15,7 +15,6 @@ import { import type { Post } from '../../../graphql/posts'; import type { ActiveFeedContextValue } from '../../../contexts'; import { ActiveFeedContext } from '../../../contexts'; -import ConditionalWrapper from '../../ConditionalWrapper'; export interface BookmarkReminderProps extends LazyModalCommonProps { onReminderSet?: (reminder: string) => void; @@ -27,33 +26,33 @@ interface BookmarkReminderModalOptionProps { isActive: boolean; onClick: () => void; option: { - key: keyof typeof ReminderPreference; + key: ReminderPreference; value: ReminderPreference; }; } +const MODAL_OPTION_ORDER: ReminderPreference[] = [ + ReminderPreference.OneHour, + ReminderPreference.Tomorrow, + ReminderPreference.TwoDays, + ReminderPreference.NextWeek, +]; + const MODAL_OPTIONS: Array = - Object.entries(ReminderPreference) - .filter(([, option]) => { - const now = new Date(); - const isPastLaterToday = now.getHours() >= 19; - const isInvalidLaterToday = - ReminderPreference.LaterToday === option && isPastLaterToday; - return !isInvalidLaterToday; - }) - .map( - ([key, value]: [ - keyof typeof ReminderPreference, - ReminderPreference, - ]) => ({ - key, - value, - }), - ); + MODAL_OPTION_ORDER.filter((option) => { + const now = new Date(); + const isPastLaterToday = now.getHours() >= 19; + const isInvalidLaterToday = + ReminderPreference.LaterToday === option && isPastLaterToday; + return !isInvalidLaterToday; + }).map((value) => ({ + key: value, + value, + })); const TIME_FOR_OPTION_FORMAT_MAP = { - [ReminderPreference.OneHour]: null, - [ReminderPreference.LaterToday]: 'h:mm a', + [ReminderPreference.OneHour]: 'eee, h:mm a', + [ReminderPreference.LaterToday]: null, [ReminderPreference.Tomorrow]: 'eee, h:mm a', [ReminderPreference.TwoDays]: 'eee, h:mm a', [ReminderPreference.NextWeek]: 'eee, MMM d, h:mm a', @@ -97,7 +96,7 @@ export const BookmarkReminderModal = ( ): ReactElement => { const { post, onReminderSet, isOpen, onRequestClose } = props; const [selectedOption, setSelectedOption] = useState( - ReminderPreference.OneHour, + ReminderPreference.NextWeek, ); const { onBookmarkReminder } = useBookmarkReminder({ post }); @@ -109,14 +108,14 @@ export const BookmarkReminderModal = ( postId: post.id, preference: selectedOption, }).then(() => { - onRequestClose(null); + onRequestClose(); }); }; return ( - +
( - ( - - {component} - - )} - > +}) => + feedContextData ? ( + + + + ) : ( - -); + ); export default ModalComponent; diff --git a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx index fd86d83df99..86fb73e4fe6 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useId } from 'react'; +import classNames from 'classnames'; import { ModalSize } from '../common/types'; import { ModalBody } from '../common/ModalBody'; import type { ModalProps } from '../common/Modal'; @@ -17,7 +18,10 @@ import type { UseStreakRecoverReturn } from '../../../hooks/streaks/useStreakRec import { useStreakRecover } from '../../../hooks/streaks/useStreakRecover'; import { Checkbox } from '../../fields/Checkbox'; import { ModalClose } from '../common/ModalClose'; -import { cloudinaryStreakLost } from '../../../lib/image'; +import { + cloudinaryNotificationsBrowser, + cloudinaryStreakLost, +} from '../../../lib/image'; import { useReadingStreak } from '../../../hooks/streaks'; import { useAuthContext } from '../../../contexts/AuthContext'; import { formatCoresCurrency } from '../../../lib/utils'; @@ -25,6 +29,13 @@ import type { UserStreakRecoverData } from '../../../graphql/users'; import { CoreIcon } from '../../icons'; import { coresDocsLink } from '../../../lib/constants'; import { anchorDefaultRel } from '../../../lib/strings'; +import { NotificationPromptSource } from '../../../lib/log'; +import { usePushNotificationMutation } from '../../../hooks/notifications'; +import { usePushNotificationContext } from '../../../contexts/PushNotificationContext'; +import usePersistentContext, { + PersistentContextKeys, +} from '../../../hooks/usePersistentContext'; +import { useNotificationCtaExperiment } from '../../../hooks/notifications/useNotificationCtaExperiment'; export interface StreakRecoverModalProps extends Pick { @@ -63,7 +74,8 @@ const StreakRecoveryCopy = ({ }) => { const { user } = useAuthContext(); const isFree = recover.cost === 0; - const canRecover = user.balance.amount >= recover.cost; + const balance = user?.balance.amount ?? 0; + const canRecover = balance >= recover.cost; const coresLink = ( ) => { const { user } = useAuthContext(); + const balance = user?.balance.amount ?? 0; return ( @@ -131,14 +144,19 @@ const StreakRecoverButton = ({ export const StreakRecoverOptout = ({ hideForever, id, + className, + compact = false, }: { id: string; + className?: string; + compact?: boolean; } & Pick): ReactElement => ( -
+
- Never show this again + {compact ? 'Hide this' : 'Never show this again'}
); +const StreakRecoverNotificationReminder = () => { + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment(); + const { isSubscribed, isInitialized, isPushSupported } = + usePushNotificationContext(); + const [isAlertShown, setIsAlertShown] = usePersistentContext( + PersistentContextKeys.StreakAlertPushKey, + true, + ); + const { onTogglePermission } = usePushNotificationMutation(); + const showAlert = + isNotificationCtaExperimentEnabled && + isPushSupported && + isAlertShown && + isInitialized && + !isSubscribed; + + if (!showAlert) { + return null; + } + + return ( +
+
+ + Get notified to keep your streak + + +
+ A sample browser notification +
+
+ +
+ + +
+
+ ); +}; + export const StreakRecoverModal = ( props: StreakRecoverModalProps, -): ReactElement => { +): ReactElement | null => { const { isOpen, onRequestClose, onAfterClose, user } = props; const { isStreaksEnabled } = useReadingStreak(); + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment(); const id = useId(); const { recover, hideForever, onClose, onRecover } = useStreakRecover({ @@ -187,7 +267,20 @@ export const StreakRecoverModal = ( title="Close streak recover popup" /> -
+ {isNotificationCtaExperimentEnabled && ( + + )} +
@@ -196,8 +289,13 @@ export const StreakRecoverModal = ( recover={recover} loading={recover.isRecoverPending} /> - + {!isNotificationCtaExperimentEnabled && ( + + )}
+ {isNotificationCtaExperimentEnabled && ( + + )} ); diff --git a/packages/shared/src/components/notifications/EnableNotification.tsx b/packages/shared/src/components/notifications/EnableNotification.tsx index 9ab8c0f8ebe..6d0e3071c89 100644 --- a/packages/shared/src/components/notifications/EnableNotification.tsx +++ b/packages/shared/src/components/notifications/EnableNotification.tsx @@ -12,25 +12,29 @@ import { cloudinaryNotificationsBrowserEnabled, cloudinaryNotificationsBrowser, } from '../../lib/image'; -import { VIcon, BellNotifyIcon } from '../icons'; +import { VIcon, BellIcon, BellNotifyIcon } from '../icons'; import { webappUrl } from '../../lib/constants'; import { NotificationPromptSource } from '../../lib/log'; +import type { NotificationCtaPlacement } from '../../lib/log'; import { useEnableNotification } from '../../hooks/notifications'; +import { NotificationSvg } from './NotificationSvg'; +import { useNotificationCtaExperiment } from '../../hooks/notifications/useNotificationCtaExperiment'; type EnableNotificationProps = { source?: NotificationPromptSource; + placement?: NotificationCtaPlacement; contentName?: string; className?: string; label?: string; + onEnableAction?: () => Promise | unknown; }; -const containerClassName: Record = { - [NotificationPromptSource.NotificationsPage]: - 'px-6 w-full border-l bg-surface-float', - [NotificationPromptSource.NewComment]: 'rounded-16 border px-4 mx-3 mb-3', +const containerClassName: Partial> = { + [NotificationPromptSource.NotificationsPage]: 'px-6 w-full bg-surface-float', + [NotificationPromptSource.NewComment]: + 'rounded-16 px-4 w-full bg-surface-float', [NotificationPromptSource.NewSourceModal]: '', [NotificationPromptSource.NotificationItem]: '', - [NotificationPromptSource.SquadPage]: 'rounded-16 border px-4 mt-6', [NotificationPromptSource.SquadPostCommentary]: '', [NotificationPromptSource.SquadPostModal]: '', [NotificationPromptSource.SquadChecklist]: '', @@ -39,11 +43,12 @@ const containerClassName: Record = { [NotificationPromptSource.BookmarkReminder]: '', }; -const sourceRenderTextCloseButton: Record = { +const sourceRenderTextCloseButton: Partial< + Record +> = { [NotificationPromptSource.NotificationsPage]: false, [NotificationPromptSource.NewComment]: false, [NotificationPromptSource.NewSourceModal]: false, - [NotificationPromptSource.SquadPage]: true, [NotificationPromptSource.SquadPostCommentary]: false, [NotificationPromptSource.SquadPostModal]: false, [NotificationPromptSource.NotificationItem]: false, @@ -56,41 +61,103 @@ const sourceRenderTextCloseButton: Record = { const sourceToButtonText: Partial> = { [NotificationPromptSource.SquadPostModal]: 'Subscribe', [NotificationPromptSource.SourceSubscribe]: 'Enable', + [NotificationPromptSource.NewComment]: 'Notify me', }; function EnableNotification({ source = NotificationPromptSource.NotificationsPage, + placement, contentName, className, label, + onEnableAction, }: EnableNotificationProps): ReactElement | null { + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment(); const { shouldShowCta, acceptedJustNow, onEnable, onDismiss } = - useEnableNotification({ source }); + useEnableNotification({ + source, + placement, + onEnableAction, + }); if (!shouldShowCta) { return null; } - const sourceToMessage: Record = { + const sourceToMessage: Partial> = { [NotificationPromptSource.SquadPostModal]: '', - [NotificationPromptSource.NewComment]: `Want to get notified when ${ - contentName ?? 'someone' - } responds so you can continue the conversation?`, + [NotificationPromptSource.NewComment]: isNotificationCtaExperimentEnabled + ? 'Someone might reply soon. Don’t miss it.' + : `Want to get notified when ${ + contentName ?? 'someone' + } responds so you can continue the conversation?`, [NotificationPromptSource.NotificationsPage]: - 'Stay in the loop whenever you get a mention, reply and other important updates.', + isNotificationCtaExperimentEnabled + ? 'Get notified when someone replies to your posts, mentions you, or when discussions you follow get new activity.' + : 'Stay in the loop whenever you get a mention, reply and other important updates.', [NotificationPromptSource.NewSourceModal]: '', [NotificationPromptSource.NotificationItem]: '', [NotificationPromptSource.SquadPostCommentary]: '', - [NotificationPromptSource.SquadPage]: `Get notified whenever something important happens on ${contentName}.`, [NotificationPromptSource.SquadChecklist]: '', [NotificationPromptSource.SourceSubscribe]: `Get notified whenever there are new posts from ${contentName}.`, [NotificationPromptSource.ReadingReminder]: '', [NotificationPromptSource.BookmarkReminder]: '', }; - const message = sourceToMessage[source]; - const classes = containerClassName[source]; - const showTextCloseButton = sourceRenderTextCloseButton[source]; + const message = sourceToMessage[source] ?? ''; + const classes = containerClassName[source] ?? ''; + const showTextCloseButton = sourceRenderTextCloseButton[source] ?? false; + const hideCloseButton = source === NotificationPromptSource.NewComment; const buttonText = sourceToButtonText[source] ?? 'Enable notifications'; + const shouldShowNotificationArtwork = + source === NotificationPromptSource.NotificationsPage; + const shouldAnimateBellCta = + source === NotificationPromptSource.NotificationsPage || + source === NotificationPromptSource.NewComment; + const shouldShowInlineNotificationImage = + source !== NotificationPromptSource.NotificationsPage && + source !== NotificationPromptSource.NewComment; + const shouldInlineActionWithMessage = + source === NotificationPromptSource.NewComment && !acceptedJustNow; + const shouldUseVerticalContentLayout = + source === NotificationPromptSource.NotificationsPage; + const notificationVisual = (() => { + if (shouldShowNotificationArtwork) { + return ( +
+ {acceptedJustNow ? ( + A sample browser notification + ) : ( + + )} +
+ ); + } + + if (!shouldShowInlineNotificationImage) { + return null; + } + + return ( + + ); + })(); if (source === NotificationPromptSource.SquadPostModal) { return ( @@ -115,71 +182,211 @@ function EnableNotification({ ); } + if (!isNotificationCtaExperimentEnabled) { + return ( +
+ {source === NotificationPromptSource.NotificationsPage && ( + + {acceptedJustNow && } + {`Push notifications${ + acceptedJustNow ? ' successfully enabled' : '' + }`} + + )} +
+

+ {acceptedJustNow ? ( + <> + Changing your{' '} + + notification settings + {' '} + can be done anytime through your account details + + ) : ( + message + )} +

+ +
+
+ {!acceptedJustNow && ( + + )} + {showTextCloseButton && ( + + )} +
+ {!showTextCloseButton && ( + + )} +
+ ); + } + return (
- {source === NotificationPromptSource.NotificationsPage && ( - - {acceptedJustNow && } - {`Push notifications${ - acceptedJustNow ? ' successfully enabled' : '' - }`} - - )} -
-

+

- {acceptedJustNow ? ( - <> - Changing your{' '} - - notification settings - {' '} - can be done anytime through your account details - - ) : ( - message + {source === NotificationPromptSource.NotificationsPage && ( +

+ {acceptedJustNow && } + Stay in the dev loop +

)} -

- + {acceptedJustNow ? ( + <> + Changing your{' '} + + notification settings + {' '} + can be done anytime through your account details + + ) : ( + message + )} +

+ {shouldInlineActionWithMessage && ( + )} - src={ - acceptedJustNow - ? cloudinaryNotificationsBrowserEnabled - : cloudinaryNotificationsBrowser - } - alt="A sample browser notification" - /> + {shouldUseVerticalContentLayout && + !acceptedJustNow && + !shouldInlineActionWithMessage && ( + + )} +
+ {notificationVisual}
-
- {!acceptedJustNow && ( - +
+ {!acceptedJustNow && + !shouldInlineActionWithMessage && + !shouldUseVerticalContentLayout && ( + + )} {showTextCloseButton && (
- {!showTextCloseButton && ( + {!showTextCloseButton && !hideCloseButton && ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/shared/src/components/notifications/Toast.tsx b/packages/shared/src/components/notifications/Toast.tsx index 5036834d2f9..f66eb232f0e 100644 --- a/packages/shared/src/components/notifications/Toast.tsx +++ b/packages/shared/src/components/notifications/Toast.tsx @@ -27,35 +27,64 @@ const Progress = classed(NotifProgress, styles.toastProgress); const Toast = ({ autoDismissNotifications = false, -}: ToastProps): ReactElement => { +}: ToastProps): ReactElement | null => { const router = useRouter(); const client = useQueryClient(); - const toastRef = useRef(null); + const toastRef = useRef(null); const { timer, isAnimating, endAnimation, startAnimation } = useTimedAnimation({ autoEndAnimation: autoDismissNotifications, onAnimationEnd: () => client.setQueryData(TOAST_NOTIF_KEY, null), }); - const { data: toast } = useQuery({ + const { data: toast = null } = useQuery({ queryKey: TOAST_NOTIF_KEY, - queryFn: () => client.getQueryData(TOAST_NOTIF_KEY), - enabled: false, + queryFn: () => + client.getQueryData(TOAST_NOTIF_KEY) ?? null, + initialData: () => + client.getQueryData(TOAST_NOTIF_KEY) ?? null, }); + const isPersistentToast = !!toast?.persistent; + + useEffect(() => { + if (!toast?.message) { + toastRef.current = null; + return; + } + + if (!toastRef.current) { + toastRef.current = toast; + if (!toast.persistent) { + startAnimation(toast.timer); + } + return; + } + + if (toastRef.current === toast) { + return; + } - if (!toastRef.current && toast?.message) { - toastRef.current = toast; - startAnimation(toast.timer); - } else if (toastRef.current && toastRef.current !== toast && toast?.message) { endAnimation(); toastRef.current = toast; - startAnimation(toast.timer); - } + if (!toast.persistent) { + startAnimation(toast.timer); + } + }, [endAnimation, startAnimation, toast]); - const dismissToast = () => { + const dismissToast = async () => { if (!toast) { return; } + if (toast.onClose) { + await toast.onClose(); + } + + if (toast.persistent) { + toastRef.current = null; + client.setQueryData(TOAST_NOTIF_KEY, null); + return; + } + endAnimation(); }; @@ -65,6 +94,12 @@ const Toast = ({ } await toast.action.onClick(); + if (toast.persistent) { + toastRef.current = null; + client.setQueryData(TOAST_NOTIF_KEY, null); + return; + } + endAnimation(); }; @@ -93,7 +128,10 @@ const Toast = ({ const progress = (timer / toast.timer) * 100; return ( - + {toast.message} {toast.action && ( @@ -102,21 +140,24 @@ const Toast = ({ size={ButtonSize.XSmall} aria-label={toast.action.copy} {...(toast.action.buttonProps ?? {})} - className={classNames('ml-2', toast.action.buttonProps?.className)} + className={classNames( + 'shrink-0', + toast.action.buttonProps?.className, + )} onClick={onAction} > {toast.action.copy} )}
)} > - - - + + + {squad.flags?.totalAwards ? ( { @@ -123,7 +125,7 @@ export function SquadPageHeader({ type: LazyModal.ListAwards, props: { queryProps: { - id: squad.id, + id: squadId, type: 'SQUAD', }, }, @@ -182,11 +184,6 @@ export function SquadPageHeader({
-
({ queryKey: generateQueryKey(RequestKey.ReadingStreak30Days, user), - queryFn: () => getReadingStreak30Days(user?.id), + queryFn: () => getReadingStreak30Days(userId ?? ''), staleTime: StaleTime.Default, + enabled: !!userId, }); const isTimezoneOk = useStreakTimezoneOk(); const { showPrompt } = usePrompt(); @@ -131,7 +134,6 @@ export function ReadingStreakPopup({ PersistentContextKeys.StreakAlertPushKey, true, ); - const { onTogglePermission, acceptedJustNow } = usePushNotificationMutation(); const showAlert = @@ -145,14 +147,14 @@ export function ReadingStreakPopup({ const streakDays = getStreakDays(today); return streakDays.map((value) => { - const isToday = isSameDayInTimezone(value, today, user?.timezone); + const isToday = isSameDayInTimezone(value, today, timezone); const streakDef = getStreak({ value, today, history, startOfWeek: streak.weekStart, - timezone: user?.timezone, + timezone, }); return ( @@ -164,11 +166,11 @@ export function ReadingStreakPopup({ /> ); }); - }, [history, streak.weekStart, user?.timezone]); + }, [history, streak.weekStart, timezone]); const onTogglePush = async () => { logEvent({ - event_name: LogEvent.DisableNotification, + event_name: LogEvent.EnableNotification, extra: JSON.stringify({ channel: NotificationChannel.Web, category: NotificationCategory.Product, @@ -261,7 +263,7 @@ export function ReadingStreakPopup({ const promptResult = await showPrompt({ title: 'Streak timezone mismatch', description: `We detected your current timezone setting ${getTimezoneOffsetLabel( - user?.timezone, + timezone, )} does not match your current device timezone ${getTimezoneOffsetLabel( deviceTimezone, )}. You can update your timezone in settings.`, @@ -295,9 +297,7 @@ export function ReadingStreakPopup({ }} href={timezoneSettingsUrl} > - {isTimezoneOk - ? user?.timezone || DEFAULT_TIMEZONE - : 'Timezone mismatch'} + {isTimezoneOk ? timezone : 'Timezone mismatch'}
@@ -328,11 +328,8 @@ export function ReadingStreakPopup({ Get notified to keep your streak -
- A sample browser notification +
+
@@ -356,9 +353,8 @@ export function ReadingStreakPopup({
)} - {acceptedJustNow && ( - <> +
@@ -376,7 +372,7 @@ export function ReadingStreakPopup({ can be done anytime through account details
- +
)}
)} diff --git a/packages/shared/src/contexts/ActiveFeedContext.tsx b/packages/shared/src/contexts/ActiveFeedContext.tsx index 51afdcae1e6..e4832c2415f 100644 --- a/packages/shared/src/contexts/ActiveFeedContext.tsx +++ b/packages/shared/src/contexts/ActiveFeedContext.tsx @@ -6,7 +6,12 @@ import type { Origin } from '../lib/log'; export type ActiveFeedContextValue = { queryKey?: QueryKey; items: FeedReturnType['items']; - logOpts?: { columns: number; row: number; column: number; is_ad?: boolean }; + logOpts?: { + columns: number; + row?: number; + column?: number; + is_ad?: boolean; + }; allowPin?: boolean; origin?: Origin; onRemovePost?: (postIndex: number) => void; diff --git a/packages/shared/src/hooks/feed/useFeedContextMenu.ts b/packages/shared/src/hooks/feed/useFeedContextMenu.ts index 4ad64bfa92c..0a976fda91b 100644 --- a/packages/shared/src/hooks/feed/useFeedContextMenu.ts +++ b/packages/shared/src/hooks/feed/useFeedContextMenu.ts @@ -4,8 +4,8 @@ import type { Post } from '../../graphql/posts'; export type PostLocation = { index: number; - row: number; - column: number; + row?: number; + column?: number; isAd?: boolean; }; @@ -23,13 +23,14 @@ type FeedContextMenu = { row: number, column: number, ) => void; - postMenuIndex: number; - postMenuLocation: PostLocation; - setPostMenuIndex: (value: PostLocation | undefined) => void; + postMenuIndex?: number; + postMenuLocation?: PostLocation | null; + setPostMenuIndex: (value?: PostLocation | null) => void; }; export default function useFeedContextMenu(): FeedContextMenu { - const [postMenuLocation, setPostMenuLocation] = useState(); + const [postMenuLocation, setPostMenuLocation] = + useState(); const postMenuIndex = postMenuLocation?.index; const onMenuClick = ( diff --git a/packages/shared/src/hooks/notifications/useEnableNotification.ts b/packages/shared/src/hooks/notifications/useEnableNotification.ts index b66db610053..cbdf18d37b9 100644 --- a/packages/shared/src/hooks/notifications/useEnableNotification.ts +++ b/packages/shared/src/hooks/notifications/useEnableNotification.ts @@ -1,15 +1,25 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { useLogContext } from '../../contexts/LogContext'; +import { useCallback, useEffect, useState } from 'react'; import usePersistentContext from '../usePersistentContext'; -import { LogEvent, NotificationPromptSource, TargetType } from '../../lib/log'; +import { + NotificationCtaKind, + NotificationPromptSource, + TargetType, +} from '../../lib/log'; +import type { NotificationCtaPlacement } from '../../lib/log'; import { usePushNotificationMutation } from './usePushNotificationMutation'; import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; import { checkIsExtension } from '../../lib/func'; +import { + useNotificationCtaAnalytics, + useNotificationCtaImpression, +} from './useNotificationCtaAnalytics'; export const DISMISS_PERMISSION_BANNER = 'DISMISS_PERMISSION_BANNER'; interface UseEnableNotificationProps { source: NotificationPromptSource; + placement?: NotificationCtaPlacement; + onEnableAction?: () => Promise | unknown; } interface UseEnableNotification { @@ -21,30 +31,81 @@ interface UseEnableNotification { export const useEnableNotification = ({ source = NotificationPromptSource.NotificationsPage, + placement, + onEnableAction, }: UseEnableNotificationProps): UseEnableNotification => { const isExtension = checkIsExtension(); - const { logEvent } = useLogContext(); - const hasLoggedImpression = useRef(false); + const { logClick, logDismiss } = useNotificationCtaAnalytics(); const { isInitialized, isPushSupported, isSubscribed, shouldOpenPopup } = usePushNotificationContext(); - const { hasPermissionCache, acceptedJustNow, onEnablePush } = - usePushNotificationMutation(); + const [hasCompletedEnableAction, setHasCompletedEnableAction] = useState( + !onEnableAction, + ); + const runEnableAction = useCallback(async (): Promise => { + if (!onEnableAction) { + setHasCompletedEnableAction(true); + return true; + } + + try { + await onEnableAction(); + setHasCompletedEnableAction(true); + return true; + } catch { + setHasCompletedEnableAction(false); + return false; + } + }, [onEnableAction]); + const { + hasPermissionCache, + acceptedJustNow: acceptedPushJustNow, + onEnablePush, + } = usePushNotificationMutation({ + onPopupGranted: () => { + runEnableAction().catch(() => null); + }, + }); const [isDismissed, setIsDismissed, isLoaded] = usePersistentContext( DISMISS_PERMISSION_BANNER, false, ); + const shouldIgnoreDismissStateForSource = + source === NotificationPromptSource.NewComment || + source === NotificationPromptSource.SquadPage; + const effectiveIsDismissed = shouldIgnoreDismissStateForSource + ? false + : isDismissed; + useEffect(() => { + setHasCompletedEnableAction(!onEnableAction); + }, [onEnableAction]); + + const acceptedJustNow = acceptedPushJustNow && hasCompletedEnableAction; + const onDismiss = useCallback(() => { - logEvent({ - event_name: LogEvent.ClickNotificationDismiss, - extra: JSON.stringify({ origin: source }), + logDismiss({ + kind: NotificationCtaKind.PushCta, + targetType: TargetType.EnableNotifications, + source, + placement, }); setIsDismissed(true); - }, [source, logEvent, setIsDismissed]); + }, [logDismiss, placement, setIsDismissed, source]); - const onEnable = useCallback( - () => onEnablePush(source), - [source, onEnablePush], - ); + const onEnable = useCallback(async () => { + logClick({ + kind: NotificationCtaKind.PushCta, + targetType: TargetType.EnableNotifications, + source, + placement, + }); + const isEnabled = await onEnablePush(source); + + if (!isEnabled) { + return false; + } + + return runEnableAction(); + }, [logClick, onEnablePush, placement, runEnableAction, source]); const subscribed = isSubscribed || (shouldOpenPopup() && hasPermissionCache); const enabledJustNow = subscribed && acceptedJustNow; @@ -56,25 +117,26 @@ export const useEnableNotification = ({ isPushSupported || isExtension, ]; - const shouldShowCta = - (conditions.every(Boolean) || - (enabledJustNow && source !== NotificationPromptSource.SquadPostModal)) && - !isDismissed; + const computeShouldShowCta = (): boolean => { + return ( + (conditions.every(Boolean) || + (enabledJustNow && + source !== NotificationPromptSource.SquadPostModal)) && + !effectiveIsDismissed + ); + }; - useEffect(() => { - if (!shouldShowCta || hasLoggedImpression.current) { - return; - } + const shouldShowCta = computeShouldShowCta(); - logEvent({ - event_name: LogEvent.Impression, - target_type: TargetType.EnableNotifications, - extra: JSON.stringify({ origin: source }), - }); - hasLoggedImpression.current = true; - // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldShowCta]); + useNotificationCtaImpression( + { + kind: NotificationCtaKind.PushCta, + targetType: TargetType.EnableNotifications, + source, + placement, + }, + shouldShowCta, + ); return { acceptedJustNow, diff --git a/packages/shared/src/hooks/notifications/useNotificationCtaAnalytics.ts b/packages/shared/src/hooks/notifications/useNotificationCtaAnalytics.ts new file mode 100644 index 00000000000..89a44c9850a --- /dev/null +++ b/packages/shared/src/hooks/notifications/useNotificationCtaAnalytics.ts @@ -0,0 +1,105 @@ +import { useCallback } from 'react'; +import { useLogContext } from '../../contexts/LogContext'; +import { + LogEvent, + NotificationCtaKind, + NotificationPromptSource, + TargetType, +} from '../../lib/log'; +import type { NotificationCtaPlacement } from '../../lib/log'; +import useLogEventOnce from '../log/useLogEventOnce'; + +type NotificationCtaTargetType = string; + +interface NotificationCtaAnalyticsParams { + kind: NotificationCtaKind; + targetType: NotificationCtaTargetType; + placement?: NotificationCtaPlacement; + source?: NotificationPromptSource; + targetId?: string; + extra?: Record; +} + +const getNotificationCtaExtra = ({ + kind, + placement, + source, + extra, +}: NotificationCtaAnalyticsParams): string => { + return JSON.stringify({ + kind, + ...(placement ? { placement } : {}), + ...(source ? { origin: source } : {}), + ...extra, + }); +}; + +const getBaseNotificationCtaEvent = ( + params: NotificationCtaAnalyticsParams, +) => ({ + target_type: params.targetType, + ...(params.targetId ? { target_id: params.targetId } : {}), + extra: getNotificationCtaExtra(params), +}); + +export const useNotificationCtaImpression = ( + params: NotificationCtaAnalyticsParams, + condition = true, +): void => { + useLogEventOnce( + () => ({ + event_name: LogEvent.ImpressionNotificationCta, + ...getBaseNotificationCtaEvent(params), + }), + { condition }, + ); +}; + +export const getReadingReminderCtaParams = ( + placement: NotificationCtaPlacement, +): NotificationCtaAnalyticsParams => ({ + kind: NotificationCtaKind.ReadingReminder, + targetType: TargetType.ReadingReminder, + source: NotificationPromptSource.ReadingReminder, + placement, +}); + +export const useNotificationCtaAnalytics = () => { + const { logEvent } = useLogContext(); + + const logClick = useCallback( + (params: NotificationCtaAnalyticsParams) => { + logEvent({ + event_name: LogEvent.ClickNotificationCta, + ...getBaseNotificationCtaEvent(params), + }); + }, + [logEvent], + ); + + const logDismiss = useCallback( + (params: NotificationCtaAnalyticsParams) => { + logEvent({ + event_name: LogEvent.ClickNotificationDismiss, + ...getBaseNotificationCtaEvent(params), + }); + }, + [logEvent], + ); + + const logImpression = useCallback( + (params: NotificationCtaAnalyticsParams) => { + logEvent({ + event_name: LogEvent.ImpressionNotificationCta, + ...getBaseNotificationCtaEvent(params), + }); + }, + [logEvent], + ); + + return { + logClick, + logDismiss, + logImpression, + }; +}; diff --git a/packages/shared/src/hooks/notifications/useNotificationCtaExperiment.ts b/packages/shared/src/hooks/notifications/useNotificationCtaExperiment.ts new file mode 100644 index 00000000000..930f81d214b --- /dev/null +++ b/packages/shared/src/hooks/notifications/useNotificationCtaExperiment.ts @@ -0,0 +1,25 @@ +import { notificationCtaV2Feature } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; + +export interface UseNotificationCtaExperiment { + isEnabled: boolean; +} + +interface UseNotificationCtaExperimentProps { + shouldEvaluate?: boolean; +} + +export const useNotificationCtaExperiment = ({ + shouldEvaluate = true, +}: UseNotificationCtaExperimentProps = {}): UseNotificationCtaExperiment => { + const { value: isFeatureEnabled } = useConditionalFeature({ + feature: notificationCtaV2Feature, + shouldEvaluate, + }); + + const isEnabled = Boolean(isFeatureEnabled); + + return { + isEnabled, + }; +}; diff --git a/packages/shared/src/hooks/notifications/useNotificationToggle.ts b/packages/shared/src/hooks/notifications/useNotificationToggle.ts index b110606a55d..ffea9372e52 100644 --- a/packages/shared/src/hooks/notifications/useNotificationToggle.ts +++ b/packages/shared/src/hooks/notifications/useNotificationToggle.ts @@ -1,11 +1,14 @@ import { useCallback, useState } from 'react'; import { NotificationPromptSource } from '../../lib/log'; import { useEnableNotification } from './useEnableNotification'; +import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; interface UseNotificationToggle { shouldShowCta: boolean; isEnabled: boolean; + isBrowserPermissionBlocked: boolean; onToggle: () => void; + onEnableNotification: () => Promise; onSubmitted: () => Promise; } @@ -20,6 +23,8 @@ export const useNotificationToggle = ({ const { shouldShowCta, onEnable, onDismiss } = useEnableNotification({ source, }); + const { shouldOpenPopup } = usePushNotificationContext(); + const isBrowserPermissionBlocked = shouldOpenPopup(); const onSubmitted = async () => { if (!shouldShowCta) { @@ -36,6 +41,8 @@ export const useNotificationToggle = ({ isEnabled, onToggle, shouldShowCta, + isBrowserPermissionBlocked, + onEnableNotification: onEnable, onSubmitted, }; }; diff --git a/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx b/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx index 638ffe5c11f..540c65bc065 100644 --- a/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx +++ b/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx @@ -107,24 +107,28 @@ export const usePushNotificationMutation = ({ [isSubscribed, onEnablePush, unsubscribe], ); - useEventListener(globalThis, 'message', async (e) => { - const { permission }: PermissionEvent = e?.data ?? {}; - const earlyReturnChecks = [ - e.data?.eventKey !== ENABLE_NOTIFICATION_WINDOW_KEY, - !shouldOpenPopup, - permission !== 'granted', - ]; - - if (earlyReturnChecks.some(Boolean)) { - return; - } + useEventListener( + typeof window === 'undefined' ? null : window, + 'message', + async (e) => { + const { permission }: PermissionEvent = e?.data ?? {}; + const earlyReturnChecks = [ + e.data?.eventKey !== ENABLE_NOTIFICATION_WINDOW_KEY, + !shouldOpenPopup(), + permission !== 'granted', + ]; + + if (earlyReturnChecks.some(Boolean)) { + return; + } - await onGranted(); + await onGranted(); - if (onPopupGranted) { - onPopupGranted(); - } - }); + if (onPopupGranted) { + onPopupGranted(); + } + }, + ); return { hasPermissionCache: permissionCache === 'granted', diff --git a/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.spec.tsx b/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.spec.tsx new file mode 100644 index 00000000000..14f2a996d79 --- /dev/null +++ b/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.spec.tsx @@ -0,0 +1,180 @@ +import { act, renderHook } from '@testing-library/react'; +import { NotificationCtaPlacement } from '../../lib/log'; +import { useReadingReminderHero } from './useReadingReminderHero'; +import { useReadingReminderVariation } from './useReadingReminderVariation'; +import { useReadingReminderFeedHero } from './useReadingReminderFeedHero'; +import { + getReadingReminderCtaParams, + useNotificationCtaAnalytics, + useNotificationCtaImpression, +} from './useNotificationCtaAnalytics'; + +const mockUseRouter = jest.fn(); +const mockUseReadingReminderHero = jest.fn(); +const mockUseReadingReminderVariation = jest.fn(); +const mockUseNotificationCtaAnalytics = jest.fn(); + +jest.mock('next/router', () => ({ + useRouter: () => mockUseRouter(), +})); + +jest.mock('../../lib/constants', () => ({ + webappUrl: '/', +})); + +jest.mock('./useReadingReminderHero', () => ({ + useReadingReminderHero: jest.fn(), +})); + +jest.mock('./useReadingReminderVariation', () => ({ + useReadingReminderVariation: jest.fn(), + ReadingReminderVariation: { + Control: 'control', + Hero: 'hero', + Inline: 'inline', + }, +})); + +jest.mock('./useNotificationCtaAnalytics', () => ({ + getReadingReminderCtaParams: jest.fn((placement) => ({ placement })), + useNotificationCtaAnalytics: jest.fn(), + useNotificationCtaImpression: jest.fn(), +})); + +describe('useReadingReminderFeedHero', () => { + const onEnable = jest.fn(() => Promise.resolve()); + const onDismiss = jest.fn(() => Promise.resolve()); + const logClick = jest.fn(); + const logDismiss = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'scrollY', { + configurable: true, + writable: true, + value: 0, + }); + + mockUseRouter.mockReturnValue({ pathname: '/' }); + mockUseReadingReminderHero.mockReturnValue({ + shouldShow: true, + title: 'Title', + subtitle: 'Subtitle', + shouldShowDismiss: true, + onEnable, + onDismiss, + }); + mockUseReadingReminderVariation.mockReturnValue({ + variation: 'control', + isControl: true, + isHero: false, + isInline: false, + }); + mockUseNotificationCtaAnalytics.mockReturnValue({ + logClick, + logDismiss, + }); + + (useReadingReminderHero as jest.Mock).mockImplementation( + mockUseReadingReminderHero, + ); + (useReadingReminderVariation as jest.Mock).mockImplementation( + mockUseReadingReminderVariation, + ); + (useNotificationCtaAnalytics as jest.Mock).mockImplementation( + mockUseNotificationCtaAnalytics, + ); + }); + + it('should not show top or inline hero for control variation', () => { + const { result } = renderHook(() => + useReadingReminderFeedHero({ itemCount: 10, itemsPerRow: 2 }), + ); + + expect(result.current.shouldShowTopHero).toBe(false); + expect(result.current.shouldShowInFeedHero).toBe(false); + }); + + it('should show the top hero variation on the homepage', async () => { + mockUseReadingReminderVariation.mockReturnValue({ + variation: 'hero', + isControl: false, + isHero: true, + isInline: false, + }); + + const { result } = renderHook(() => + useReadingReminderFeedHero({ itemCount: 10, itemsPerRow: 2 }), + ); + + expect(result.current.shouldShowTopHero).toBe(true); + expect(result.current.shouldShowInFeedHero).toBe(false); + + await act(async () => { + await result.current.onEnableHero(NotificationCtaPlacement.TopHero); + }); + + expect(logClick).toHaveBeenCalledWith( + getReadingReminderCtaParams(NotificationCtaPlacement.TopHero), + ); + expect(onEnable).toHaveBeenCalledTimes(1); + }); + + it('should show the inline variation only after scroll', () => { + mockUseReadingReminderVariation.mockReturnValue({ + variation: 'inline', + isControl: false, + isHero: false, + isInline: true, + }); + + const { result } = renderHook(() => + useReadingReminderFeedHero({ itemCount: 10, itemsPerRow: 2 }), + ); + + expect(result.current.shouldShowTopHero).toBe(false); + expect(result.current.shouldShowInFeedHero).toBe(false); + + act(() => { + window.scrollY = 350; + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.shouldShowInFeedHero).toBe(true); + expect(useNotificationCtaImpression).toHaveBeenCalledWith( + getReadingReminderCtaParams(NotificationCtaPlacement.InFeedHero), + true, + ); + }); + + it('should not show the inline variation when there are not enough items', () => { + mockUseReadingReminderVariation.mockReturnValue({ + variation: 'inline', + isControl: false, + isHero: false, + isInline: true, + }); + + const { result } = renderHook(() => + useReadingReminderFeedHero({ itemCount: 6, itemsPerRow: 1 }), + ); + + act(() => { + window.scrollY = 350; + window.dispatchEvent(new Event('scroll')); + }); + + expect(result.current.shouldShowInFeedHero).toBe(false); + }); + + it('should not show either variation off the homepage', () => { + mockUseRouter.mockReturnValue({ pathname: '/bookmarks' }); + + const { result } = renderHook(() => + useReadingReminderFeedHero({ itemCount: 10, itemsPerRow: 2 }), + ); + + expect(result.current.shouldShowTopHero).toBe(false); + expect(result.current.shouldShowInFeedHero).toBe(false); + }); +}); diff --git a/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.ts b/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.ts new file mode 100644 index 00000000000..404f7fefc8f --- /dev/null +++ b/packages/shared/src/hooks/notifications/useReadingReminderFeedHero.ts @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { NotificationCtaPlacement } from '../../lib/log'; +import { webappUrl } from '../../lib/constants'; +import { useReadingReminderHero } from './useReadingReminderHero'; +import { + getReadingReminderCtaParams, + useNotificationCtaAnalytics, + useNotificationCtaImpression, +} from './useNotificationCtaAnalytics'; +import { useReadingReminderVariation } from './useReadingReminderVariation'; + +const HERO_INSERT_INDEX = 6; +const HERO_SCROLL_THRESHOLD_PX = 300; +type ReadingReminderHeroPlacement = + | NotificationCtaPlacement.TopHero + | NotificationCtaPlacement.InFeedHero; + +interface UseReadingReminderFeedHeroProps { + itemCount: number; + itemsPerRow: number; +} + +interface UseReadingReminderFeedHero { + heroInsertIndex: number; + shouldShowTopHero: boolean; + shouldShowInFeedHero: boolean; + title: string; + subtitle: string; + shouldShowDismiss: boolean; + onEnableHero: (placement: ReadingReminderHeroPlacement) => Promise; + onDismissHero: (placement: ReadingReminderHeroPlacement) => Promise; +} + +const getInitialDismissedPlacements = (): Record< + ReadingReminderHeroPlacement, + boolean +> => ({ + [NotificationCtaPlacement.TopHero]: false, + [NotificationCtaPlacement.InFeedHero]: false, +}); + +export const useReadingReminderFeedHero = ({ + itemCount, + itemsPerRow, +}: UseReadingReminderFeedHeroProps): UseReadingReminderFeedHero => { + const safeItemsPerRow = Math.max(1, itemsPerRow); + const heroInsertIndex = + Math.ceil(HERO_INSERT_INDEX / safeItemsPerRow) * safeItemsPerRow; + const { pathname } = useRouter(); + const { + shouldShow, + title, + subtitle, + shouldShowDismiss, + onEnable, + onDismiss, + } = useReadingReminderHero({ + requireMobile: false, + }); + const isHomePage = pathname === webappUrl; + const shouldEvaluateReminderPlacement = isHomePage && shouldShow; + const { isHero, isInline } = useReadingReminderVariation({ + shouldEvaluate: shouldEvaluateReminderPlacement, + }); + const { logClick, logDismiss } = useNotificationCtaAnalytics(); + const [hasScrolledForHero, setHasScrolledForHero] = useState(false); + const [dismissedPlacements, setDismissedPlacements] = useState( + getInitialDismissedPlacements, + ); + + useEffect(() => { + if (!shouldShow || !isInline || hasScrolledForHero) { + return undefined; + } + + const onScroll = () => { + if (window.scrollY >= HERO_SCROLL_THRESHOLD_PX) { + setHasScrolledForHero(true); + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [hasScrolledForHero, isInline, shouldShow]); + + useEffect(() => { + if (!shouldShow) { + setHasScrolledForHero(false); + setDismissedPlacements(getInitialDismissedPlacements()); + } + }, [shouldShow]); + + const canShowReminderPlacements = shouldEvaluateReminderPlacement; + const shouldShowTopHero = + canShowReminderPlacements && + isHero && + !dismissedPlacements[NotificationCtaPlacement.TopHero]; + const shouldShowInFeedHero = + canShowReminderPlacements && + isInline && + hasScrolledForHero && + !dismissedPlacements[NotificationCtaPlacement.InFeedHero] && + itemCount > heroInsertIndex; + + useNotificationCtaImpression( + getReadingReminderCtaParams(NotificationCtaPlacement.TopHero), + shouldShowTopHero, + ); + useNotificationCtaImpression( + getReadingReminderCtaParams(NotificationCtaPlacement.InFeedHero), + shouldShowInFeedHero, + ); + + const setPlacementDismissed = useCallback( + (placement: ReadingReminderHeroPlacement) => { + setDismissedPlacements((current) => ({ + ...current, + [placement]: true, + })); + }, + [], + ); + + const onEnableHero = useCallback( + async (placement: ReadingReminderHeroPlacement) => { + logClick(getReadingReminderCtaParams(placement)); + await onEnable(); + setPlacementDismissed(placement); + }, + [logClick, onEnable, setPlacementDismissed], + ); + + const onDismissHero = useCallback( + async (placement: ReadingReminderHeroPlacement) => { + setPlacementDismissed(placement); + logDismiss(getReadingReminderCtaParams(placement)); + await onDismiss(); + }, + [logDismiss, onDismiss, setPlacementDismissed], + ); + + return { + heroInsertIndex, + shouldShowTopHero, + shouldShowInFeedHero, + title, + subtitle, + shouldShowDismiss, + onEnableHero, + onDismissHero, + }; +}; diff --git a/packages/shared/src/hooks/notifications/useReadingReminderHero.spec.tsx b/packages/shared/src/hooks/notifications/useReadingReminderHero.spec.tsx index 1b825ef1f5b..6d36056401c 100644 --- a/packages/shared/src/hooks/notifications/useReadingReminderHero.spec.tsx +++ b/packages/shared/src/hooks/notifications/useReadingReminderHero.spec.tsx @@ -7,10 +7,10 @@ import { featureReadingReminderHeroCopy, featureReadingReminderHeroDismiss, } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; const mockUseAuthContext = jest.fn(); const mockUseLogContext = jest.fn(); -const mockUseConditionalFeature = jest.fn(); const mockUsePersonalizedDigest = jest.fn(); const mockPersistentContext = jest.fn(); const mockUseViewSize = jest.fn(); @@ -24,10 +24,6 @@ jest.mock('../../contexts/LogContext', () => ({ useLogContext: () => mockUseLogContext(), })); -jest.mock('../useConditionalFeature', () => ({ - useConditionalFeature: (...args) => mockUseConditionalFeature(...args), -})); - jest.mock('../usePersonalizedDigest', () => ({ SendType: { Weekly: 'weekly', @@ -42,20 +38,24 @@ jest.mock('../usePersistentContext', () => ({ PersistentContextKeys: { ReadingReminderLastSeen: 'reading_reminder_last_seen', }, - default: (...args) => mockPersistentContext(...args), + default: (...args: unknown[]) => mockPersistentContext(...args), })); jest.mock('../useViewSize', () => ({ ViewSize: { MobileL: 767, }, - useViewSize: (...args) => mockUseViewSize(...args), + useViewSize: (...args: unknown[]) => mockUseViewSize(...args), })); jest.mock('./usePushNotificationMutation', () => ({ usePushNotificationMutation: () => mockUsePushNotificationMutation(), })); +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + describe('useReadingReminderHero', () => { const logEvent = jest.fn(); const subscribePersonalizedDigest = jest.fn(() => Promise.resolve(null)); @@ -70,7 +70,7 @@ describe('useReadingReminderHero', () => { user: { timezone: 'UTC' }, }); mockUseLogContext.mockReturnValue({ logEvent }); - mockUseConditionalFeature.mockImplementation(({ feature }) => { + (useConditionalFeature as jest.Mock).mockImplementation(({ feature }) => { if (feature.id === featureReadingReminderHeroDismiss.id) { return { value: true, @@ -131,9 +131,6 @@ describe('useReadingReminderHero', () => { expect(result.current.shouldShow).toBe(false); expect(setLastSeen).not.toHaveBeenCalled(); - expect(mockUseConditionalFeature).toHaveBeenCalledWith( - expect.objectContaining({ shouldEvaluate: false }), - ); }); it('should not show when dismissed', () => { @@ -157,6 +154,24 @@ describe('useReadingReminderHero', () => { expect(result.current.shouldShowDismiss).toBe(true); }); + it('should not show on desktop', () => { + mockUseViewSize.mockReturnValue(false); + + const { result } = renderHook(() => useReadingReminderHero()); + + expect(result.current.shouldShow).toBe(false); + }); + + it('should show on desktop when mobile requirement is disabled', () => { + mockUseViewSize.mockReturnValue(false); + + const { result } = renderHook(() => + useReadingReminderHero({ requireMobile: false }), + ); + + expect(result.current.shouldShow).toBe(true); + }); + it('should enable reminder and log schedule event', async () => { const { result } = renderHook(() => useReadingReminderHero()); diff --git a/packages/shared/src/hooks/notifications/useReadingReminderHero.ts b/packages/shared/src/hooks/notifications/useReadingReminderHero.ts index b6b14056afa..d763aea36fb 100644 --- a/packages/shared/src/hooks/notifications/useReadingReminderHero.ts +++ b/packages/shared/src/hooks/notifications/useReadingReminderHero.ts @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLogContext } from '../../contexts/LogContext'; import { UserPersonalizedDigestType } from '../../graphql/users'; -import { useConditionalFeature } from '../useConditionalFeature'; import { SendType, usePersonalizedDigest } from '../usePersonalizedDigest'; import usePersistentContext, { PersistentContextKeys, @@ -15,6 +14,7 @@ import { featureReadingReminderHeroCopy, featureReadingReminderHeroDismiss, } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; interface UseReadingReminderHero { shouldShow: boolean; @@ -25,6 +25,10 @@ interface UseReadingReminderHero { onDismiss: () => Promise; } +interface UseReadingReminderHeroProps { + requireMobile?: boolean; +} + const DEFAULT_READING_REMINDER_HOUR = 9; const READING_REMINDER_DISMISSED = 'dismissed'; @@ -57,7 +61,9 @@ const getIsRegisteredToday = (createdAt?: string | Date): boolean => { return isToday(parsedDate); }; -export const useReadingReminderHero = (): UseReadingReminderHero => { +export const useReadingReminderHero = ({ + requireMobile = true, +}: UseReadingReminderHeroProps = {}): UseReadingReminderHero => { const { isLoggedIn, user } = useAuthContext(); const { logEvent } = useLogContext(); const { onEnablePush } = usePushNotificationMutation(); @@ -79,8 +85,9 @@ export const useReadingReminderHero = (): UseReadingReminderHero => { const isDismissed = isDismissedValue(lastSeen); const isMobile = useViewSize(ViewSize.MobileL); + const isEligibleViewSize = !requireMobile || isMobile; const shouldEvaluate = - isMobile && + isEligibleViewSize && isLoggedIn && !isDigestLoading && !isSubscribedToReadingReminder && diff --git a/packages/shared/src/hooks/notifications/useReadingReminderVariation.spec.tsx b/packages/shared/src/hooks/notifications/useReadingReminderVariation.spec.tsx new file mode 100644 index 00000000000..44034c56e8e --- /dev/null +++ b/packages/shared/src/hooks/notifications/useReadingReminderVariation.spec.tsx @@ -0,0 +1,66 @@ +import { renderHook } from '@testing-library/react'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { + ReadingReminderVariation, + useReadingReminderVariation, +} from './useReadingReminderVariation'; + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +describe('useReadingReminderVariation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return hero variation', () => { + (useConditionalFeature as jest.Mock).mockReturnValue({ + value: ReadingReminderVariation.Hero, + }); + + const { result } = renderHook(() => useReadingReminderVariation()); + + expect(result.current.variation).toBe(ReadingReminderVariation.Hero); + expect(result.current.isHero).toBe(true); + expect(result.current.isInline).toBe(false); + }); + + it('should return inline variation', () => { + (useConditionalFeature as jest.Mock).mockReturnValue({ + value: ReadingReminderVariation.Inline, + }); + + const { result } = renderHook(() => useReadingReminderVariation()); + + expect(result.current.variation).toBe(ReadingReminderVariation.Inline); + expect(result.current.isHero).toBe(false); + expect(result.current.isInline).toBe(true); + }); + + it('should return control variation', () => { + (useConditionalFeature as jest.Mock).mockReturnValue({ + value: ReadingReminderVariation.Control, + }); + + const { result } = renderHook(() => useReadingReminderVariation()); + + expect(result.current.variation).toBe(ReadingReminderVariation.Control); + expect(result.current.isControl).toBe(true); + expect(result.current.isHero).toBe(false); + expect(result.current.isInline).toBe(false); + }); + + it('should fall back to hero for invalid values', () => { + (useConditionalFeature as jest.Mock).mockReturnValue({ + value: 'unexpected', + }); + + const { result } = renderHook(() => useReadingReminderVariation()); + + expect(result.current.variation).toBe(ReadingReminderVariation.Control); + expect(result.current.isControl).toBe(true); + expect(result.current.isHero).toBe(false); + expect(result.current.isInline).toBe(false); + }); +}); diff --git a/packages/shared/src/hooks/notifications/useReadingReminderVariation.ts b/packages/shared/src/hooks/notifications/useReadingReminderVariation.ts new file mode 100644 index 00000000000..ebdce62045d --- /dev/null +++ b/packages/shared/src/hooks/notifications/useReadingReminderVariation.ts @@ -0,0 +1,51 @@ +import { featureReadingReminderVariation } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; + +export const ReadingReminderVariation = { + Control: 'control', + Hero: 'hero', + Inline: 'inline', +} as const; + +export type ReadingReminderVariationType = + (typeof ReadingReminderVariation)[keyof typeof ReadingReminderVariation]; + +const validVariations = new Set( + Object.values(ReadingReminderVariation), +); + +export const getReadingReminderVariation = ( + value: unknown, +): ReadingReminderVariationType => { + return validVariations.has(value as ReadingReminderVariationType) + ? (value as ReadingReminderVariationType) + : ReadingReminderVariation.Control; +}; + +interface UseReadingReminderVariationProps { + shouldEvaluate?: boolean; +} + +interface UseReadingReminderVariation { + variation: ReadingReminderVariationType; + isControl: boolean; + isHero: boolean; + isInline: boolean; +} + +export const useReadingReminderVariation = ({ + shouldEvaluate = true, +}: UseReadingReminderVariationProps = {}): UseReadingReminderVariation => { + const { value } = useConditionalFeature({ + feature: featureReadingReminderVariation, + shouldEvaluate, + }); + const variation = getReadingReminderVariation(value); + + return { + variation, + isControl: variation === ReadingReminderVariation.Control, + isHero: variation === ReadingReminderVariation.Hero, + isInline: variation === ReadingReminderVariation.Inline, + }; +}; diff --git a/packages/shared/src/hooks/post/common.ts b/packages/shared/src/hooks/post/common.ts index 0a74f311682..393cc332f6d 100644 --- a/packages/shared/src/hooks/post/common.ts +++ b/packages/shared/src/hooks/post/common.ts @@ -2,10 +2,10 @@ import type { CommentMarkdownInputProps } from '../../components/fields/Markdown export interface CommentWriteProps { commentId: string; - parentCommentId?: string; + parentCommentId?: string | null; lastUpdatedAt?: string; } export interface CommentWrite { - inputProps: Partial; + inputProps: Partial | null; } diff --git a/packages/shared/src/hooks/post/useComments.ts b/packages/shared/src/hooks/post/useComments.ts index 6ef9de7e1c9..cd7b6db9a1e 100644 --- a/packages/shared/src/hooks/post/useComments.ts +++ b/packages/shared/src/hooks/post/useComments.ts @@ -10,12 +10,12 @@ import { AuthTriggers } from '../../lib/auth'; import type { CommentWrite, CommentWriteProps } from './common'; interface ReplyTo extends CommentWriteProps { - username: string; + username: string | null; } interface UseComments extends CommentWrite { - commentId: string; - onReplyTo: (params: ReplyTo) => void; + commentId: string | null; + onReplyTo: (params: ReplyTo | null) => void; } export const getReplyToInitialContent = ( @@ -26,7 +26,7 @@ export const useComments = (post: Post): UseComments => { const { logEvent } = useLogContext(); const { logOpts } = useActiveFeedContext(); const { user, showLogin } = useAuthContext(); - const [replyTo, setReplyTo] = useState(null); + const [replyTo, setReplyTo] = useState(null); const inputProps = useMemo(() => { if (!replyTo) { @@ -36,14 +36,14 @@ export const useComments = (post: Post): UseComments => { const { username, parentCommentId } = replyTo ?? {}; return { - parentCommentId, - replyTo: username, - initialContent: getReplyToInitialContent(username), + ...(parentCommentId ? { parentCommentId } : {}), + ...(username ? { replyTo: username } : {}), + initialContent: getReplyToInitialContent(username ?? undefined), }; }, [replyTo]); const onReplyTo = useCallback( - (params: ReplyTo) => { + (params: ReplyTo | null) => { if (!user) { return showLogin({ trigger: AuthTriggers.Comment }); } @@ -73,6 +73,6 @@ export const useComments = (post: Post): UseComments => { return { onReplyTo, inputProps, - commentId: replyTo?.commentId, + commentId: replyTo?.commentId ?? null, }; }; diff --git a/packages/shared/src/hooks/post/useEditCommentProps.ts b/packages/shared/src/hooks/post/useEditCommentProps.ts index 1f35c41571c..0720e250476 100644 --- a/packages/shared/src/hooks/post/useEditCommentProps.ts +++ b/packages/shared/src/hooks/post/useEditCommentProps.ts @@ -3,13 +3,13 @@ import type { CommentWrite, CommentWriteProps } from './common'; import useCommentById from '../comments/useCommentById'; interface UseCommentEdit extends CommentWrite { - onEdit: (params: CommentWriteProps) => void; + onEdit: (params: CommentWriteProps | null) => void; } export const useEditCommentProps = (): UseCommentEdit => { - const [state, setState] = useState(); + const [state, setState] = useState(null); const { commentId: id } = state ?? {}; - const { comment, onEdit } = useCommentById({ id }); + const { comment, onEdit } = useCommentById({ id: id ?? '' }); const inputProps = useMemo(() => { if (!state || !comment) { @@ -18,8 +18,10 @@ export const useEditCommentProps = (): UseCommentEdit => { return { editCommentId: state.commentId, - parentCommentId: state.parentCommentId, - initialContent: comment?.content, + ...(state.parentCommentId + ? { parentCommentId: state.parentCommentId } + : {}), + ...(comment?.content ? { initialContent: comment.content } : {}), }; }, [comment, state]); diff --git a/packages/shared/src/hooks/source/useSourceActionsNotify.ts b/packages/shared/src/hooks/source/useSourceActionsNotify.ts index 06ca9dbff99..315fe779dd6 100644 --- a/packages/shared/src/hooks/source/useSourceActionsNotify.ts +++ b/packages/shared/src/hooks/source/useSourceActionsNotify.ts @@ -12,7 +12,7 @@ import { import { useToastNotification } from '../useToastNotification'; export type UseSourceSubscriptionProps = { - source: Pick | Source; + source?: Pick | Source | null; }; export type UseSourceSubscription = { @@ -61,7 +61,8 @@ export const useSourceActionsNotify = ({ target_type: TargetType.Source, }); - const displayName = 'name' in source ? source.name : source?.id; + const displayName = + source && 'name' in source && source.name ? source.name : source?.id; displayToast( notifications.isSubscribed diff --git a/packages/shared/src/hooks/squads/usePostToSquad.tsx b/packages/shared/src/hooks/squads/usePostToSquad.tsx index 1c4c89a579f..6d6af7752b9 100644 --- a/packages/shared/src/hooks/squads/usePostToSquad.tsx +++ b/packages/shared/src/hooks/squads/usePostToSquad.tsx @@ -25,7 +25,8 @@ import { getApiError, gqlClient, } from '../../graphql/common'; -import { useToastNotification } from '../useToastNotification'; +import { useToastNotification, ToastSubject } from '../useToastNotification'; +import type { NotifyOptionalProps } from '../useToastNotification'; import type { SourcePostModeration } from '../../graphql/squads'; import { addPostToSquad, updateSquadPost } from '../../graphql/squads'; import { ActionType } from '../../graphql/actions'; @@ -38,6 +39,20 @@ import { moderationRequired } from '../../components/squads/utils'; import useNotificationSettings from '../notifications/useNotificationSettings'; import { ButtonSize } from '../../components/buttons/common'; import { BellIcon } from '../../components/icons'; +import { + ButtonIconPosition, + ButtonVariant, +} from '../../components/buttons/Button'; +import { usePushNotificationContext } from '../../contexts/PushNotificationContext'; +import { usePushNotificationMutation } from '../notifications/usePushNotificationMutation'; +import { + NotificationCtaKind, + NotificationCtaPlacement, + NotificationPromptSource, + TargetType, +} from '../../lib/log'; +import { useNotificationCtaExperiment } from '../notifications/useNotificationCtaExperiment'; +import { useNotificationCtaAnalytics } from '../notifications/useNotificationCtaAnalytics'; const isApiErrorResult = (error: unknown): error is ApiErrorResult => !!(error as ApiErrorResult)?.response?.errors; @@ -76,6 +91,9 @@ interface UsePostToSquadProps { onPostSuccess?: (post: Post, url: string) => void; onSourcePostModerationSuccess?: (data: SourcePostModeration) => void; onExternalLinkSuccess?: (data: ExternalLinkPreview, url: string) => void; + getSharedPostSuccessToast?: (params: { + isUpdate: boolean; + }) => { message: string; options?: NotifyOptionalProps } | undefined; initialPreview?: ExternalLinkPreview; onMutate?: (data: unknown) => void; onError?: (error: ApiErrorResult) => void; @@ -95,11 +113,17 @@ export const usePostToSquad = ({ onError, onExternalLinkSuccess, onSourcePostModerationSuccess, + getSharedPostSuccessToast, initialPreview, }: UsePostToSquadProps = {}): UsePostToSquad => { const { toggleGroup, getGroupStatus } = useNotificationSettings(); const { displayToast } = useToastNotification(); const { user } = useAuthContext(); + const { isSubscribed } = usePushNotificationContext(); + const { onEnablePush } = usePushNotificationMutation(); + const { logClick, logImpression } = useNotificationCtaAnalytics(); + const { isEnabled: isNotificationCtaExperimentEnabled } = + useNotificationCtaExperiment(); const client = useQueryClient(); const { completeAction } = useActions(); const [preview, setPreview] = useState( @@ -107,6 +131,8 @@ export const usePostToSquad = ({ ); const { requestMethod: requestMethodContext } = useRequestProtocol(); const requestMethod = requestMethodContext ?? gqlClient.request; + const shouldShowEnableNotificationToast = + isNotificationCtaExperimentEnabled && !isSubscribed; const handleMutationError = useCallback( (err: unknown): void => { @@ -236,11 +262,44 @@ export const usePostToSquad = ({ ); const onSharedPostSuccessfully = async (update = false) => { - displayToast( - update - ? 'The post has been updated' - : 'This post has been shared to your squad', - ); + const customToast = getSharedPostSuccessToast?.({ isUpdate: update }); + if (customToast) { + displayToast(customToast.message, customToast.options); + } else if (!update && shouldShowEnableNotificationToast) { + logImpression({ + kind: NotificationCtaKind.ToastCta, + targetType: TargetType.EnableNotifications, + source: NotificationPromptSource.SquadPostCommentary, + placement: NotificationCtaPlacement.SquadShareToast, + }); + displayToast('Post shared. Don’t miss the replies.', { + subject: ToastSubject.Feed, + action: { + copy: 'Turn on', + onClick: async () => { + logClick({ + kind: NotificationCtaKind.ToastCta, + targetType: TargetType.EnableNotifications, + source: NotificationPromptSource.SquadPostCommentary, + placement: NotificationCtaPlacement.SquadShareToast, + }); + await onEnablePush(NotificationPromptSource.SquadPostCommentary); + }, + buttonProps: { + size: ButtonSize.Small, + variant: ButtonVariant.Primary, + icon: , + iconPosition: ButtonIconPosition.Left, + }, + }, + }); + } else { + displayToast( + update + ? 'The post has been updated' + : 'This post has been shared to your squad', + ); + } await client.invalidateQueries({ queryKey: ['sourceFeed', user?.id], }); diff --git a/packages/shared/src/hooks/useCoresFeature.ts b/packages/shared/src/hooks/useCoresFeature.ts index 6628443aa16..f09913e56e8 100644 --- a/packages/shared/src/hooks/useCoresFeature.ts +++ b/packages/shared/src/hooks/useCoresFeature.ts @@ -1,17 +1,17 @@ import { useAuthContext } from '../contexts/AuthContext'; import { canAwardUser, hasAccessToCores } from '../lib/cores'; -import type { PropsParameters } from '../types'; import { useIsSpecialUser } from './auth/useIsSpecialUser'; import { iOSSupportsCoresPurchase } from '../lib/ios'; import { isIOSNative } from '../lib/func'; import type { Squad, SourceMember } from '../graphql/sources'; import { SourceMemberRole } from '../graphql/sources'; import type { LoggedUser, UserShortProfile } from '../lib/user'; +import { CoresRole } from '../lib/user'; const useCoresFeature = (): boolean => { const { user } = useAuthContext(); - return !!user && user.coresRole > 0; + return !!user && (user.coresRole ?? CoresRole.None) > CoresRole.None; }; export const useHasAccessToCores = (): boolean => { @@ -19,17 +19,30 @@ export const useHasAccessToCores = (): boolean => { const hasAccess = useCoresFeature(); - return hasAccess && hasAccessToCores(user); + return hasAccess && !!user && hasAccessToCores(user); }; -export const useCanAwardUser = ( - props: PropsParameters, -): boolean => { - const isSpecialUser = useIsSpecialUser({ userId: props.receivingUser?.id }); +interface UseCanAwardUserProps { + sendingUser?: LoggedUser; + receivingUser?: LoggedUser; +} + +export const useCanAwardUser = (props: UseCanAwardUserProps): boolean => { + const isSpecialUser = useIsSpecialUser({ + userId: props.receivingUser?.id ?? '', + }); const hasAccess = useCoresFeature(); - return hasAccess && !isSpecialUser && canAwardUser(props); + if (!props.sendingUser || !props.receivingUser) { + return false; + } + + const { sendingUser, receivingUser } = props; + + return ( + hasAccess && !isSpecialUser && canAwardUser({ sendingUser, receivingUser }) + ); }; interface UseGetSquadAwardAdminProps { diff --git a/packages/shared/src/hooks/useSourceMenuProps.tsx b/packages/shared/src/hooks/useSourceMenuProps.tsx index 7ab4696dbfd..1a25938348b 100644 --- a/packages/shared/src/hooks/useSourceMenuProps.tsx +++ b/packages/shared/src/hooks/useSourceMenuProps.tsx @@ -11,20 +11,28 @@ const useSourceMenuProps = ({ source, feedId, }: { - source: SourceTooltip; + source?: SourceTooltip | null; feedId?: string; }) => { const router = useRouter(); const { follow, unfollow } = useContentPreference(); - const sourceId = source.id ?? ''; + const sourceId = source?.id ?? ''; const onCreateNewFeed = () => { + if (!source?.id) { + return; + } + router.push( `${webappUrl}feeds/new?entityId=${sourceId}&entityType=${ContentPreferenceType.Source}`, ); }; const onUndo = () => { + if (!source?.id || !source.handle) { + return; + } + unfollow({ id: sourceId, entity: ContentPreferenceType.Source, @@ -34,6 +42,10 @@ const useSourceMenuProps = ({ }; const onAdd = () => { + if (!source?.id || !source.handle) { + return; + } + follow({ id: sourceId, entity: ContentPreferenceType.Source, @@ -43,12 +55,14 @@ const useSourceMenuProps = ({ }; const shareProps: UseShareOrCopyLinkProps = { - text: `Check out ${source.handle} on daily.dev`, - link: source.permalink, + text: source?.handle + ? `Check out ${source.handle} on daily.dev` + : 'Check out this source on daily.dev', + link: source?.permalink || webappUrl, cid: ReferralCampaignKey.ShareSource, logObject: () => ({ event_name: LogEvent.ShareSource, - target_id: sourceId, + ...(source?.id ? { target_id: source.id } : {}), }), }; diff --git a/packages/shared/src/hooks/useTimedAnimation.ts b/packages/shared/src/hooks/useTimedAnimation.ts index d482f9c8520..7dec61c9d4c 100644 --- a/packages/shared/src/hooks/useTimedAnimation.ts +++ b/packages/shared/src/hooks/useTimedAnimation.ts @@ -26,6 +26,7 @@ export const useTimedAnimation = ({ }: UseTimedAnimationProps): UseTimedAnimation => { const [timer, setTimer] = useState(0); const interval = useRef(); + const hasStartedAnimation = useRef(false); const [animationEnd] = useDebounceFn( onAnimationEnd ?? (() => undefined), outAnimationDuration, @@ -60,6 +61,8 @@ export const useTimedAnimation = ({ return; } + hasStartedAnimation.current = true; + if (!autoEndAnimation) { interval.current = MANUAL_DISMISS_ANIMATION_ID; } @@ -78,8 +81,9 @@ export const useTimedAnimation = ({ useEffect(() => { // when the timer ends we need to do cleanups // we delay the callback execution so we can let the slide out animation finish - if (timer <= 0) { + if (timer <= 0 && hasStartedAnimation.current) { clearInterval(); + hasStartedAnimation.current = false; animationEnd(); } // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM diff --git a/packages/shared/src/hooks/useToastNotification.ts b/packages/shared/src/hooks/useToastNotification.ts index eb7f0c3f507..144b68bd0bd 100644 --- a/packages/shared/src/hooks/useToastNotification.ts +++ b/packages/shared/src/hooks/useToastNotification.ts @@ -21,6 +21,8 @@ export interface ToastNotification { message: ReactNode; timer: number; subject?: ToastSubject; + persistent?: boolean; + onClose?: AnyFunction; action?: { onClick: AnyFunction; buttonProps?: ButtonProps<'button'>; @@ -31,7 +33,10 @@ export interface ToastNotification { export const TOAST_NOTIF_KEY = ['toast_notif']; export type NotifyOptionalProps = Partial< - Pick + Pick< + ToastNotification, + 'timer' | 'subject' | 'persistent' | 'onClose' | 'action' + > >; export const useToastNotification = (): UseToastNotification => { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 4cff62c1f39..92d2fcb59f2 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -79,6 +79,15 @@ export const featureReadingReminderHeroDismiss = new Feature( false, ); +export const notificationCtaV2Feature = new Feature( + 'notification_cta_v2', + false, +); + +export const featureReadingReminderVariation = new Feature< + 'control' | 'hero' | 'inline' +>('reading_reminder_variation', 'control'); + export const featureReadingReminderHeroCopy = new Feature( 'reading_reminder_hero_copy', { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index e61c6b0a654..cf25dbb2af2 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -107,8 +107,10 @@ export enum LogEvent { ClickNotificationIcon = 'click notification icon', OpenNotificationList = 'open notification list', ClickNotification = 'click notification', + ClickNotificationCta = 'click notification cta', ClickEnableNotification = 'click enable notification', ClickNotificationDismiss = 'click notification dismiss', + ImpressionNotificationCta = 'impression notification cta', EnableNotification = 'enable notification', DisableNotification = 'disable notification', ScheduleDigest = 'schedule digest', @@ -540,6 +542,24 @@ export enum NotificationTarget { Icon = 'notifications icon', } +export enum NotificationCtaPlacement { + TopHero = 'top-hero', + InFeedHero = 'in-feed-hero', + CommentInline = 'comment-inline', + UserCard = 'user-card', + SourceCard = 'source-card', + SquadCard = 'squad-card', + PostActions = 'post-actions', + SquadShareToast = 'squad-share-toast', +} + +export enum NotificationCtaKind { + PushCta = 'push_cta', + ReadingReminder = 'reading_reminder', + FollowUpCta = 'followup_cta', + ToastCta = 'toast_cta', +} + export enum NotificationPromptSource { BookmarkReminder = 'bookmark reminder', NotificationsPage = 'notifications page', diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index 8c7a66e546b..e78aa463608 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1055,4 +1055,31 @@ meter::-webkit-meter-bar { .float-animation { animation: float 1.5s ease-in-out infinite; } + + .top-hero-panel-border { + background: linear-gradient(122deg, #2d1b8f 0%, #5d1fb7 45%, #ff00a8 100%); + } + + .top-hero-glow { + background: rgb(255 0 168 / 35%); + } + + @keyframes enable-notification-bell-ring { + 0%, + 100% { + transform: rotate(0deg); + } + 20% { + transform: rotate(-16deg); + } + 40% { + transform: rotate(14deg); + } + 60% { + transform: rotate(-10deg); + } + 80% { + transform: rotate(8deg); + } + } } diff --git a/packages/webapp/lib/squadNotificationToastState.ts b/packages/webapp/lib/squadNotificationToastState.ts new file mode 100644 index 00000000000..ece80873019 --- /dev/null +++ b/packages/webapp/lib/squadNotificationToastState.ts @@ -0,0 +1,128 @@ +interface SquadNotificationToastState { + date: string; + shownSquadIds: string[]; + joinedMemberSquadIds: string[]; + dismissed: boolean; +} + +interface RegisterToastViewParams { + squadId: string; + isSquadMember: boolean; +} + +interface DismissToastParams { + squadId: string; +} + +export interface SquadNotificationToastStateStore { + registerToastView: (params: RegisterToastViewParams) => boolean; + dismissUntilTomorrow: (params: DismissToastParams) => void; +} + +const STATE_KEY_PREFIX = 'SQUAD_NOTIF_TOAST_STATE'; + +const getTodayDateKey = (): string => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; + +const getStorageKey = (userId?: string): string => + `${STATE_KEY_PREFIX}:${userId ?? 'anonymous'}`; + +const getDefaultState = (): SquadNotificationToastState => ({ + date: getTodayDateKey(), + shownSquadIds: [], + joinedMemberSquadIds: [], + dismissed: false, +}); + +const readState = (storageKey: string): SquadNotificationToastState => { + if (typeof window === 'undefined') { + return getDefaultState(); + } + + const fallback = getDefaultState(); + + try { + const rawState = window.localStorage.getItem(storageKey); + if (!rawState) { + return fallback; + } + + const parsedState = JSON.parse(rawState) as SquadNotificationToastState; + if (parsedState.date !== fallback.date) { + return fallback; + } + + return { + date: parsedState.date, + shownSquadIds: parsedState.shownSquadIds ?? [], + joinedMemberSquadIds: parsedState.joinedMemberSquadIds ?? [], + dismissed: !!parsedState.dismissed, + }; + } catch { + return fallback; + } +}; + +const writeState = ( + storageKey: string, + state: SquadNotificationToastState, +): void => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(storageKey, JSON.stringify(state)); +}; + +export const createSquadNotificationToastStateStore = ( + userId?: string, +): SquadNotificationToastStateStore => { + const storageKey = getStorageKey(userId); + + return { + registerToastView: ({ squadId, isSquadMember }) => { + const state = readState(storageKey); + const isFreshJoinEvent = + isSquadMember && !state.joinedMemberSquadIds.includes(squadId); + + if (isFreshJoinEvent) { + state.joinedMemberSquadIds.push(squadId); + } + + if (state.dismissed) { + writeState(storageKey, state); + return false; + } + + const hasShownAnyToastToday = state.shownSquadIds.length > 0; + const shouldShowToast = isFreshJoinEvent || !hasShownAnyToastToday; + if (!shouldShowToast) { + writeState(storageKey, state); + return false; + } + + if (!state.shownSquadIds.includes(squadId)) { + state.shownSquadIds.push(squadId); + } + + writeState(storageKey, state); + return true; + }, + dismissUntilTomorrow: ({ squadId }) => { + const state = readState(storageKey); + + if (!state.shownSquadIds.includes(squadId)) { + state.shownSquadIds.push(squadId); + } + + state.dismissed = true; + writeState(storageKey, state); + }, + }; +}; diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index d71ad5f9182..abf0f82c5fe 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -1,10 +1,11 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; import type { ParsedUrlQuery } from 'querystring'; import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { NextSeoProps } from 'next-seo'; import Head from 'next/head'; import Feed from '@dailydotdev/shared/src/components/Feed'; +import { BellIcon } from '@dailydotdev/shared/src/components/icons'; import { SOURCE_FEED_QUERY, supportedTypesForPrivateSources, @@ -24,12 +25,13 @@ import { import type { BasicSourceMember, Squad, - Source, } from '@dailydotdev/shared/src/graphql/sources'; -import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; import Unauthorized from '@dailydotdev/shared/src/components/errors/Unauthorized'; import { useQuery } from '@tanstack/react-query'; -import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { + LogEvent, + NotificationPromptSource, +} from '@dailydotdev/shared/src/lib/log'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import dynamic from 'next/dynamic'; import useSidebarRendered from '@dailydotdev/shared/src/hooks/useSidebarRendered'; @@ -50,6 +52,17 @@ import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { usePrivateSourceJoin } from '@dailydotdev/shared/src/hooks/source/usePrivateSourceJoin'; import { GET_REFERRING_USER_QUERY } from '@dailydotdev/shared/src/graphql/users'; import type { PublicProfile } from '@dailydotdev/shared/src/lib/user'; +import { + ToastSubject, + useToastNotification, +} from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { useEnableNotification } from '@dailydotdev/shared/src/hooks/notifications/useEnableNotification'; +import { + ButtonColor, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; import { mainFeedLayoutProps } from '../../../components/layouts/MainFeedPage'; import { getLayout } from '../../../components/layouts/FeedLayout'; import type { ProtectedPageProps } from '../../../components/ProtectedPage'; @@ -58,6 +71,7 @@ import { getSquadOpenGraph } from '../../../next-seo'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import type { DynamicSeoProps } from '../../../components/common'; import { getAppOrigin } from '../../../lib/seo'; +import { createSquadNotificationToastStateStore } from '../../../lib/squadNotificationToastState'; const Custom404 = dynamic( () => import(/* webpackChunkName: "404" */ '../../404'), @@ -79,7 +93,6 @@ const SquadLoading = dynamic( ); const appOrigin = getAppOrigin(); - const getSquadPageJsonLd = (squad: SquadStaticData): string => { const squadUrl = squad.permalink; @@ -164,12 +177,78 @@ const SquadPage = ({ const { openModal } = useLazyModal(); useJoinReferral(); const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); const { sidebarRendered } = useSidebarRendered(); const { shouldUseListFeedLayout, shouldUseListMode } = useFeedLayout(); const { user, isFetched: isBootFetched } = useAuthContext(); const [loggedImpression, setLoggedImpression] = useState(false); const { squad, isLoading, isFetched, isForbidden } = useSquad({ handle }); const squadId = squad?.id; + const shownToastForSquadInSession = useRef>({}); + const squadNotificationToastState = useMemo( + () => createSquadNotificationToastStateStore(user?.id), + [user?.id], + ); + const { shouldShowCta, onEnable } = useEnableNotification({ + source: NotificationPromptSource.SquadPage, + }); + + useEffect(() => { + if ( + !shouldShowCta || + !squadId || + !isFetched || + shownToastForSquadInSession.current[squadId] + ) { + return; + } + + const shouldShowToast = squadNotificationToastState.registerToastView({ + squadId, + isSquadMember: !!squad?.currentMember, + }); + if (!shouldShowToast) { + return; + } + + shownToastForSquadInSession.current[squadId] = true; + + displayToast('Get notified about new Squad activity.', { + subject: ToastSubject.Feed, + persistent: true, + action: { + copy: 'Turn on', + onClick: async () => { + const didEnable = await onEnable(); + if (!didEnable) { + squadNotificationToastState.dismissUntilTomorrow({ squadId }); + } + + return didEnable; + }, + buttonProps: { + size: ButtonSize.Small, + variant: ButtonVariant.Primary, + color: ButtonColor.Cabbage, + icon: ( + + ), + iconPosition: ButtonIconPosition.Left, + }, + }, + onClose: () => { + squadNotificationToastState.dismissUntilTomorrow({ squadId }); + }, + }); + }, [ + displayToast, + isFetched, + onEnable, + shouldShowCta, + squad?.currentMember, + squadId, + squadNotificationToastState, + ]); useEffect(() => { if (loggedImpression || !squadId) { @@ -187,7 +266,7 @@ const SquadPage = ({ const { data: squadMembers } = useQuery({ queryKey: ['squadMembersInitial', handle], - queryFn: () => getSquadMembers(squadId), + queryFn: () => getSquadMembers(squadId ?? ''), enabled: isBootFetched && !!squadId, staleTime: StaleTime.OneHour, }); @@ -278,7 +357,7 @@ const SquadPage = ({
@@ -321,7 +400,12 @@ export async function getServerSideProps({ }: GetServerSidePropsContext): Promise< GetServerSidePropsResult > { - const { handle } = params; + const handle = params?.handle; + if (!handle) { + return { + notFound: true, + }; + } const { userid: userId, cid: campaign } = query; const setCacheHeader = () => { @@ -332,43 +416,23 @@ export async function getServerSideProps({ }; try { - const promises: [Promise, Promise?] = [ + const referringUserPromise = + userId && campaign + ? gqlClient + .request<{ user: SourcePageProps['referringUser'] }>( + GET_REFERRING_USER_QUERY, + { + id: userId, + }, + ) + .then((data) => data?.user) + .catch(() => undefined) + : Promise.resolve(undefined); + + const [squad, referringUser] = await Promise.all([ getSquadStaticFields(handle), - ]; - - if (userId && campaign) { - promises.push( - gqlClient - .request<{ user: SourcePageProps['referringUser'] }>( - GET_REFERRING_USER_QUERY, - { - id: userId, - }, - ) - .then((data) => data?.user) - .catch(() => undefined), - ); - } - - const [squad, referringUser] = await Promise.all(promises); - - if (squad?.type === SourceType.User) { - return { - redirect: { - destination: `/${squad.id}`, - permanent: false, - }, - }; - } - - if (squad?.type === SourceType.Machine) { - return { - redirect: { - destination: `/sources/${handle}`, - permanent: false, - }, - }; - } + referringUserPromise, + ]); setCacheHeader(); @@ -393,7 +457,7 @@ export async function getServerSideProps({ seo, handle, initialData: squad as Squad, - referringUser: referringUser || null, + referringUser: referringUser ?? undefined, ...(squad.public && { jsonLd: getSquadPageJsonLd(squad as SquadStaticData), }), diff --git a/packages/webapp/public/assets/reading-reminder-cat.png b/packages/webapp/public/assets/reading-reminder-cat.png new file mode 100644 index 00000000000..97f880cd74e Binary files /dev/null and b/packages/webapp/public/assets/reading-reminder-cat.png differ