Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ figma-images/

# Build cache
*.tsbuildinfo
node-compile-cache/
node-compile-cache/

# Plan files
plans/*.md
6 changes: 5 additions & 1 deletion packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useRouter } from 'next/router';
import type { QueryKey } from '@tanstack/react-query';
import type { PostItem, UseFeedOptionalParams } from '../hooks/useFeed';
import useFeed, { isBoostedPostAd } from '../hooks/useFeed';
import type { Post } from '../graphql/posts';
import type { Ad, Post } from '../graphql/posts';
import { PostType } from '../graphql/posts';
import AuthContext from '../contexts/AuthContext';
import FeedContext from '../contexts/FeedContext';
Expand Down Expand Up @@ -86,6 +86,7 @@ export interface FeedProps<T>
showSearch?: boolean;
actionButtons?: ReactNode;
disableAds?: boolean;
staticAd?: { ad: Ad; index: number };
allowFetchMore?: boolean;
pageSize?: number;
isHorizontal?: boolean;
Expand Down Expand Up @@ -157,6 +158,7 @@ export const PostModalMap: Record<PostType, typeof ArticlePostModal> = {
[PostType.VideoYouTube]: ArticlePostModal,
[PostType.Collection]: CollectionPostModal,
[PostType.Brief]: BriefPostModal,
[PostType.Digest]: ArticlePostModal,
[PostType.Poll]: PollPostModal,
[PostType.SocialTwitter]: SocialTwitterPostModal,
};
Expand All @@ -177,6 +179,7 @@ export default function Feed<T>({
shortcuts,
actionButtons,
disableAds,
staticAd,
allowFetchMore,
pageSize,
isHorizontal = false,
Expand Down Expand Up @@ -264,6 +267,7 @@ export default function Feed<T>({
options,
settings: {
disableAds,
staticAd,
adPostLength: isSquadFeed ? 2 : undefined,
showAcquisitionForm,
...(showMarketingCta && { marketingCta }),
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const PostTypeToTagCard: Record<PostType, React.ComponentType<any>> = {
[PostType.Brief]: BriefCard,
[PostType.Poll]: PollGrid,
[PostType.SocialTwitter]: SocialTwitterGrid,
[PostType.Digest]: ArticleGrid,
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -121,6 +122,7 @@ const PostTypeToTagList: Record<PostType, React.ComponentType<any>> = {
[PostType.Brief]: BriefCard,
[PostType.Poll]: PollList,
[PostType.SocialTwitter]: SocialTwitterList,
[PostType.Digest]: ArticleList,
};

const getPostTypeForCard = (post?: Post): PostType => {
Expand Down
170 changes: 170 additions & 0 deletions packages/shared/src/components/notifications/DigestNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { SetStateAction } from 'react';
import React, { useMemo, useState } from 'react';
import { UserPersonalizedDigestType } from '../../graphql/users';
import { SendType, usePersonalizedDigest } from '../../hooks';
import { LogEvent, NotificationCategory } from '../../lib/log';
import { useLogContext } from '../../contexts/LogContext';
import { useAuthContext } from '../../contexts/AuthContext';
import { HourDropdown } from '../fields/HourDropdown';
import { usePushNotificationContext } from '../../contexts/PushNotificationContext';
import useNotificationSettings from '../../hooks/notifications/useNotificationSettings';
import { NotificationType } from './utils';
import NotificationSwitch from './NotificationSwitch';
import { isNullOrUndefined } from '../../lib/func';
import { Radio } from '../fields/Radio';

const digestCopy = `Our recommendation system scans everything on daily.dev and
sends you a tailored digest with just the must-read posts.
Choose when and how often you get them.`;

const DigestNotification = () => {
const { notificationSettings: ns, toggleSetting } = useNotificationSettings();
const { isPushSupported } = usePushNotificationContext();
const { user } = useAuthContext();
const { logEvent } = useLogContext();
const {
getPersonalizedDigest,
isLoading,
subscribePersonalizedDigest,
unsubscribePersonalizedDigest,
} = usePersonalizedDigest();
const [digestTimeIndex, setDigestTimeIndex] = useState<number | undefined>(8);

const digest = useMemo(() => {
if (isLoading) {
return null;
}

return getPersonalizedDigest(UserPersonalizedDigestType.Digest);
}, [getPersonalizedDigest, isLoading]);

if (!isNullOrUndefined(digest) && digest?.preferredHour !== digestTimeIndex) {
setDigestTimeIndex(digest.preferredHour);
}

const onLogToggle = (isEnabled: boolean, category: NotificationCategory) => {
logEvent({
event_name: isEnabled
? LogEvent.EnableNotification
: LogEvent.DisableNotification,
extra: JSON.stringify({ channel: 'inApp', category }),
});
};

const setCustomTime = (
type: UserPersonalizedDigestType,
preferredHour: number,
setHour: React.Dispatch<SetStateAction<number | undefined>>,
): void => {
logEvent({
event_name: LogEvent.ScheduleDigest,
extra: JSON.stringify({
hour: preferredHour,
timezone: user?.timezone,
frequency: digest.flags.sendType,
}),
});
subscribePersonalizedDigest({
type,
hour: preferredHour,
sendType: digest.flags.sendType,
flags: digest.flags,
});
setHour(preferredHour);
};

const isChecked = ns?.[NotificationType.DigestReady]?.inApp === 'subscribed';

const onToggleDigest = () => {
toggleSetting(NotificationType.DigestReady, 'inApp');
onLogToggle(isChecked, NotificationCategory.Digest);

// Email for digest is managed via BriefingReady in the email tab
const emailActive =
ns?.[NotificationType.BriefingReady]?.email === 'subscribed';

if (isChecked && !emailActive) {
// Turning off in-app and email is already off → fully unsubscribe
unsubscribePersonalizedDigest({
type: UserPersonalizedDigestType.Digest,
});
} else if (!digest) {
subscribePersonalizedDigest({
type: UserPersonalizedDigestType.Digest,
sendType: SendType.Workdays,
});
}
};

const onSubscribeDigest = async ({
type,
sendType,
preferredHour,
}: {
type: UserPersonalizedDigestType;
sendType: SendType;
preferredHour?: number;
}): Promise<void> => {
onLogToggle(true, NotificationCategory.Digest);

logEvent({
event_name: LogEvent.ScheduleDigest,
extra: JSON.stringify({
hour: digestTimeIndex,
timezone: user?.timezone,
frequency: sendType,
type,
}),
});

await subscribePersonalizedDigest({
type,
sendType,
hour: preferredHour ?? digest?.preferredHour,
});
};

return (
<div className="flex w-full flex-col gap-4">
<NotificationSwitch
id={NotificationType.DigestReady}
label="Personalized digest"
description={digestCopy}
checked={isChecked}
onToggle={onToggleDigest}
/>
{!!digest && isChecked && (
<>
<h3 className="font-bold typo-callout">When to send</h3>
<HourDropdown
className={{
container: 'w-40',
...(!isPushSupported && { menu: '-translate-y-[19rem]' }),
}}
hourIndex={digestTimeIndex}
setHourIndex={(hour) =>
setCustomTime(digest.type, hour, setDigestTimeIndex)
}
/>
<Radio
name="digestSendType"
value={digest?.flags?.sendType ?? null}
options={[
{ label: 'Daily', value: SendType.Daily },
{ label: 'Workdays (Mon-Fri)', value: SendType.Workdays },
{ label: 'Weekly', value: SendType.Weekly },
]}
onChange={(sendType) => {
onSubscribeDigest({
type: digest.type,
sendType,
});
}}
/>
</>
)}
</div>
);
};

export default DigestNotification;
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
NotificationPromptSource,
} from '../../lib/log';
import { HorizontalSeparator } from '../utilities';
import DigestNotification from './DigestNotification';
import PresidentialBriefingNotification from './PresidentialBriefingNotification';
import { useLogContext } from '../../contexts/LogContext';
import SquadModNotifications from './SquadModNotifications';
Expand Down Expand Up @@ -252,6 +253,10 @@ const InAppNotificationsTab = (): ReactElement => {
</NotificationContainer>
</NotificationSection>
<HorizontalSeparator className="mx-4" />
<NotificationSection>
<DigestNotification />
</NotificationSection>
<HorizontalSeparator className="mx-4" />
<NotificationSection>
<PresidentialBriefingNotification />
</NotificationSection>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,13 @@ const PersonalizedDigest = () => {

if (isChecked) {
if (selectedDigest?.type === UserPersonalizedDigestType.Digest) {
unsubscribePersonalizedDigest({
type: UserPersonalizedDigestType.Digest,
});
const digestInAppActive =
ns?.[NotificationType.DigestReady]?.inApp === 'subscribed';
if (!digestInAppActive) {
unsubscribePersonalizedDigest({
type: UserPersonalizedDigestType.Digest,
});
}
}

if (
Expand Down
119 changes: 119 additions & 0 deletions packages/shared/src/components/notifications/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { isMutingDigestCompletely, NotificationType } from './utils';
import type { NotificationSettings } from './utils';

describe('isMutingDigestCompletely', () => {
describe('with default BriefingReady type', () => {
it('should return true when other channel is muted and current is subscribed', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
email: 'muted',
inApp: 'subscribed',
},
};

expect(isMutingDigestCompletely(ns, 'inApp')).toBe(true);
});

it('should return false when other channel is subscribed', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
email: 'subscribed',
inApp: 'subscribed',
},
};

expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false);
});

it('should return false when current channel is already muted', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
email: 'muted',
inApp: 'muted',
},
};

expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false);
});

it('should return true when checking email channel with inApp muted', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
email: 'subscribed',
inApp: 'muted',
},
};

expect(isMutingDigestCompletely(ns, 'email')).toBe(true);
});
});

describe('with DigestReady type', () => {
it('should return true when other channel is muted and current is subscribed', () => {
const ns: NotificationSettings = {
[NotificationType.DigestReady]: {
email: 'muted',
inApp: 'subscribed',
},
};

expect(
isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady),
).toBe(true);
});

it('should return false when other channel is subscribed', () => {
const ns: NotificationSettings = {
[NotificationType.DigestReady]: {
email: 'subscribed',
inApp: 'subscribed',
},
};

expect(
isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady),
).toBe(false);
});

it('should not be affected by BriefingReady settings', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
email: 'muted',
inApp: 'subscribed',
},
[NotificationType.DigestReady]: {
email: 'subscribed',
inApp: 'subscribed',
},
};

// DigestReady has both subscribed, so should be false
expect(
isMutingDigestCompletely(ns, 'inApp', NotificationType.DigestReady),
).toBe(false);

// BriefingReady has email muted, so should be true with default
expect(isMutingDigestCompletely(ns, 'inApp')).toBe(true);
});
});

describe('edge cases', () => {
it('should return false when notification type is not in settings', () => {
const ns: NotificationSettings = {};

expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false);
});

it('should return false when settings are partially defined', () => {
const ns: NotificationSettings = {
[NotificationType.BriefingReady]: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
email: undefined as any,
inApp: 'subscribed',
},
};

expect(isMutingDigestCompletely(ns, 'inApp')).toBe(false);
});
});
});
Loading