diff --git a/packages/shared/src/components/Icon.tsx b/packages/shared/src/components/Icon.tsx index 90ad6d74b8..cdc055fbdf 100644 --- a/packages/shared/src/components/Icon.tsx +++ b/packages/shared/src/components/Icon.tsx @@ -1,5 +1,5 @@ -import type { ComponentProps, ReactElement } from 'react'; -import React from 'react'; +import type { ComponentProps, ReactElement, ReactNode } from 'react'; +import React, { Children } from 'react'; import classNames from 'classnames'; export enum IconSize { @@ -62,4 +62,38 @@ const Icon = ({ ); }; +export /** + * Icon wrapper so we can use more then single element inside the icon + * prop on different components. Wrapper automatically applies icon + * props as size to all children. + */ +const IconWrapper = ({ + size, + wrapperClassName, + children, + ...rest +}: Omit & { + wrapperClassName?: string; + children: ReactNode; +}): ReactElement => { + return ( +
+ {Children.map(children, (child) => { + if (React.isValidElement(child)) { + // so that className is no exposed from outside since components + // like Button override it for icons + const { className } = rest as { className: string }; + + return React.cloneElement(child as ReactElement, { + size, + className: classNames(child.props.className, className), + }); + } + + return child; + })} +
+ ); +}; + export default Icon; diff --git a/packages/shared/src/components/icons/Warning/filled.svg b/packages/shared/src/components/icons/Warning/filled.svg index 4b3296d11a..69483aed95 100644 --- a/packages/shared/src/components/icons/Warning/filled.svg +++ b/packages/shared/src/components/icons/Warning/filled.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/packages/shared/src/components/icons/Warning/outlined.svg b/packages/shared/src/components/icons/Warning/outlined.svg index 45bb02cf16..9d3b51f8fe 100644 --- a/packages/shared/src/components/icons/Warning/outlined.svg +++ b/packages/shared/src/components/icons/Warning/outlined.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/packages/shared/src/components/modals/Prompt.tsx b/packages/shared/src/components/modals/Prompt.tsx index fcca95a53f..165e524144 100644 --- a/packages/shared/src/components/modals/Prompt.tsx +++ b/packages/shared/src/components/modals/Prompt.tsx @@ -34,6 +34,7 @@ export function PromptElement(props: Partial): ReactElement { cancelButton = {}, okButton = {}, className = {}, + shouldCloseOnOverlayClick, }, } = prompt; return ( @@ -46,6 +47,7 @@ export function PromptElement(props: Partial): ReactElement { overlayClassName="!z-max" isDrawerOnMobile drawerProps={{ displayCloseButton: false, appendOnRoot: true }} + shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} {...props} > diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index 5aa48f617b..520dae7752 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -4,7 +4,7 @@ import classnames from 'classnames'; import { ReadingStreakPopup } from './popup'; import type { ButtonIconPosition } from '../buttons/Button'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { ReadingStreakIcon } from '../icons'; +import { ReadingStreakIcon, WarningIcon } from '../icons'; import { SimpleTooltip } from '../tooltips'; import type { UserStreak } from '../../graphql/users'; import { useViewSize, ViewSize } from '../../hooks'; @@ -17,6 +17,8 @@ import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; +import { IconWrapper } from '../Icon'; +import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; interface ReadingStreakButtonProps { streak: UserStreak; @@ -77,6 +79,7 @@ export function ReadingStreakButton({ const hasReadToday = streak?.lastViewAt && isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); + const isTimezoneOk = useStreakTimezoneOk(); const handleToggle = useCallback(() => { setShouldShowStreaks((state) => !state); @@ -118,7 +121,14 @@ export function ReadingStreakButton({ id="reading-streak-header-button" type="button" iconPosition={iconPosition} - icon={} + icon={ + + + {!isTimezoneOk && ( + + )} + + } variant={ isLaptop || isMobile ? ButtonVariant.Tertiary : ButtonVariant.Float } diff --git a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx index 628c86674d..5889c0a534 100644 --- a/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx +++ b/packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx @@ -4,6 +4,7 @@ import { addDays, subDays } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import classNames from 'classnames'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import { StreakSection } from './StreakSection'; import { DayStreak, Streak } from './DayStreak'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; @@ -13,15 +14,31 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import { useActions, useViewSize, ViewSize } from '../../../hooks'; import { ActionType } from '../../../graphql/actions'; import { Button, ButtonVariant } from '../../buttons/Button'; -import { SettingsIcon } from '../../icons'; +import { SettingsIcon, WarningIcon } from '../../icons'; import StreakReminderSwitch from '../StreakReminderSwitch'; import ReadingStreakSwitch from '../ReadingStreakSwitch'; import { useToggle } from '../../../hooks/useToggle'; import { ToggleWeekStart } from '../../widgets/ToggleWeekStart'; import { isWeekend, DayOfWeek } from '../../../lib/date'; -import { DEFAULT_TIMEZONE, isSameDayInTimezone } from '../../../lib/timezones'; +import { + DEFAULT_TIMEZONE, + getTimezoneOffsetLabel, + isSameDayInTimezone, +} from '../../../lib/timezones'; import { SimpleTooltip } from '../../tooltips'; import { isTesting } from '../../../lib/constants'; +import { + timezoneMismatchIgnoreKey, + useStreakTimezoneOk, +} from '../../../hooks/streaks/useStreakTimezoneOk'; +import { usePrompt } from '../../../hooks/usePrompt'; +import usePersistentContext from '../../../hooks/usePersistentContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { + LogEvent, + StreakTimezonePromptAction, + TargetId, +} from '../../../lib/log'; const getStreak = ({ value, @@ -82,10 +99,13 @@ interface ReadingStreakPopupProps { fullWidth?: boolean; } +const timezoneSettingsHref = '/account/notifications?s=timezone'; + export function ReadingStreakPopup({ streak, fullWidth, }: ReadingStreakPopupProps): ReactElement { + const router = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); const { user } = useAuthContext(); const { completeAction } = useActions(); @@ -95,6 +115,11 @@ export function ReadingStreakPopup({ staleTime: StaleTime.Default, }); const [showStreakConfig, toggleShowStreakConfig] = useToggle(false); + const isTimezoneOk = useStreakTimezoneOk(); + const { showPrompt } = usePrompt(); + const [timezoneMismatchIgnore, setTimezoneMismatchIgnore] = + usePersistentContext(timezoneMismatchIgnoreKey, ''); + const { logEvent } = useLogContext(); const streaks = useMemo(() => { const today = new Date(); @@ -159,16 +184,89 @@ export function ReadingStreakPopup({ forceLoad={!isTesting} content={
- We are showing your reading streak in your selected timezone. -
- Click to adjust your timezone if needed or traveling. + {isTimezoneOk ? ( + <> + We are showing your reading streak in your selected + timezone. +
+ Click to adjust your timezone if needed or traveling. + + ) : ( + <>Click for more info + )}
} > -
- - {user.timezone || DEFAULT_TIMEZONE} - +
+ {!isTimezoneOk && ( + + )} +
+ { + const deviceTimezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + const eventExtra = { + device_timezone: deviceTimezone, + user_timezone: user.timezone, + timezone_ok: isTimezoneOk, + timezone_ignore: timezoneMismatchIgnore, + }; + + logEvent({ + event_name: LogEvent.Click, + target_type: TargetId.StreakTimezoneLabel, + extra: JSON.stringify(eventExtra), + }); + + if (isTimezoneOk) { + return; + } + + event.preventDefault(); + + const promptResult = await showPrompt({ + title: 'Streak timezone mismatch', + description: `We detected your current timezone setting ${getTimezoneOffsetLabel( + user?.timezone, + )} does not match your current device timezone ${getTimezoneOffsetLabel( + deviceTimezone, + )}. You can update your timezone in settings.`, + okButton: { + title: 'Go to settings', + }, + cancelButton: { + title: 'Ignore', + }, + shouldCloseOnOverlayClick: false, + }); + + logEvent({ + event_name: LogEvent.Click, + target_type: TargetId.StreakTimezoneMismatchPrompt, + extra: JSON.stringify({ + ...eventExtra, + action: promptResult + ? StreakTimezonePromptAction.Settings + : StreakTimezonePromptAction.Ignore, + }), + }); + + if (!promptResult) { + setTimezoneMismatchIgnore(deviceTimezone); + + return; + } + + router.push(timezoneSettingsHref); + }} + href={timezoneSettingsHref} + > + {isTimezoneOk + ? user.timezone || DEFAULT_TIMEZONE + : 'Timezone mismatch'} + +
diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 390e9b6c13..20ca26f72c 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -36,6 +36,7 @@ export enum ActionType { DigestConfig = 'digest_config', StreakMilestone = 'streak_milestone', FetchedSmartTitle = 'fetched_smart_title', + StreakTimezoneMismatch = 'streak_timezone_mismatch', } export interface Action { diff --git a/packages/shared/src/hooks/streaks/useStreakTimezoneOk.ts b/packages/shared/src/hooks/streaks/useStreakTimezoneOk.ts new file mode 100644 index 0000000000..f072c80622 --- /dev/null +++ b/packages/shared/src/hooks/streaks/useStreakTimezoneOk.ts @@ -0,0 +1,73 @@ +import { getTimezoneOffset } from 'date-fns-tz'; +import { useEffect, useMemo } from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { DEFAULT_TIMEZONE } from '../../lib/timezones'; +import usePersistentContext from '../usePersistentContext'; +import { useActions } from '../useActions'; +import { ActionType } from '../../graphql/actions'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent } from '../../lib/log'; + +export const timezoneMismatchIgnoreKey = 'timezoneMismatchIgnore'; + +export const useStreakTimezoneOk = (): boolean => { + const { user, isLoggedIn } = useAuthContext(); + const { checkHasCompleted, isActionsFetched, completeAction } = useActions(); + const { logEvent } = useLogContext(); + + const [ignoredTimezone] = usePersistentContext(timezoneMismatchIgnoreKey, ''); + const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const isTimezoneOk = useMemo(() => { + if (ignoredTimezone === deviceTimezone) { + return true; + } + + if (!isLoggedIn) { + return true; + } + + return ( + getTimezoneOffset(user?.timezone || DEFAULT_TIMEZONE) === + getTimezoneOffset(Intl.DateTimeFormat().resolvedOptions().timeZone) + ); + }, [deviceTimezone, ignoredTimezone, isLoggedIn, user?.timezone]); + + // once off check to see how many users with timezone mismatches we have in the wild + useEffect(() => { + if (isTimezoneOk) { + return; + } + + if (!isActionsFetched) { + return; + } + + if (checkHasCompleted(ActionType.StreakTimezoneMismatch)) { + return; + } + + logEvent({ + event_name: LogEvent.StreakTimezoneMismatch, + extra: JSON.stringify({ + device_timezone: deviceTimezone, + user_timezone: user?.timezone, + timezone_ok: isTimezoneOk, + timezone_ignore: ignoredTimezone, + }), + }); + + completeAction(ActionType.StreakTimezoneMismatch); + }, [ + isTimezoneOk, + ignoredTimezone, + isActionsFetched, + checkHasCompleted, + completeAction, + logEvent, + deviceTimezone, + user?.timezone, + ]); + + return isTimezoneOk; +}; diff --git a/packages/shared/src/hooks/usePrompt.ts b/packages/shared/src/hooks/usePrompt.ts index a4ddad05b4..845a32c75b 100644 --- a/packages/shared/src/hooks/usePrompt.ts +++ b/packages/shared/src/hooks/usePrompt.ts @@ -25,6 +25,7 @@ export type PromptOptions = { description?: string; buttons?: string; }; + shouldCloseOnOverlayClick?: boolean; }; type Prompt = { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index f63463d2cb..3677d5769c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -170,6 +170,7 @@ export enum LogEvent { ScheduleStreakReminder = 'schedule streak reminder', StreakRecover = 'restore streak', DismissStreakRecover = 'dimiss streaks milestone', + StreakTimezoneMismatch = 'streak timezone mismatch', // 404 page View404Page = '404 page', // Follow Actions - start @@ -313,6 +314,8 @@ export enum TargetId { BookmarkFolder = 'bookmark folder', FeedSettings = 'feed settings', ClickbaitShield = 'clickbait shield', + StreakTimezoneLabel = 'streak timezone label', + StreakTimezoneMismatchPrompt = 'streak timezone mismatch prompt', } export enum NotificationChannel { @@ -361,3 +364,8 @@ export enum UserAcquisitionEvent { Dismiss = 'dismiss ua', Submit = 'choose ua', } + +export enum StreakTimezonePromptAction { + Settings = 'settings', + Ignore = 'ignore', +} diff --git a/packages/shared/src/lib/timezones.ts b/packages/shared/src/lib/timezones.ts index 465184234b..fba637aa15 100644 --- a/packages/shared/src/lib/timezones.ts +++ b/packages/shared/src/lib/timezones.ts @@ -1,4 +1,4 @@ -import { utcToZonedTime } from 'date-fns-tz'; +import { getTimezoneOffset, utcToZonedTime } from 'date-fns-tz'; import type { FC } from 'react'; import formatInTimeZone from 'date-fns-tz/formatInTimeZone'; import { DaytimeIcon, NighttimeIcon } from '../components/icons/TimeZone'; @@ -615,3 +615,11 @@ export const isSameDayInTimezone = ( dateFormatInTimezone(date2, dateFormat, timezone) ); }; + +export const getTimezoneOffsetLabel = (timezone: string): string => { + // from ms to hours + const timezoneOffset = + getTimezoneOffset(timezone || DEFAULT_TIMEZONE) / (60 * 60 * 1000); + + return `(UTC ${timezoneOffset > 0 ? '+' : ''}${timezoneOffset}) ${timezone}`; +};