diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 0959b068bd..355a646f0e 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -6,7 +6,6 @@ import {moderateStatus} from '@bsky/sdk/moderation' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {isAfter, parseISO} from 'date-fns' import {uploadBlob} from '#/lib/api' import {imageToThumb} from '#/lib/api/resolve' @@ -23,7 +22,11 @@ import {useTickEveryMinute} from '#/state/shell' import {useDialogContext} from '#/components/Dialog' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' -import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils' +import { + getLiveNowHost, + getLiveServiceNames, + isStatusStillActive, +} from '#/features/liveNow/utils' import {app, com} from '#/lexicons' import * as bsky from '#/types/bsky' @@ -151,14 +154,6 @@ export function useActorStatus(actor?: bsky.profile.AnyProfileView) { }, [shadowed, config, tick, moderation]) } -export function isStatusStillActive(timeStr: string | undefined) { - if (!timeStr) return false - const now = new Date() - const expiry = parseISO(timeStr) - - return isAfter(expiry, now) -} - /** * Validates whether the live status is valid for display in the app. Does NOT * validate if the status is valid for the acting user e.g. as they go live. diff --git a/src/features/liveNow/isStatusStillActive.test.ts b/src/features/liveNow/isStatusStillActive.test.ts new file mode 100644 index 0000000000..79eac563eb --- /dev/null +++ b/src/features/liveNow/isStatusStillActive.test.ts @@ -0,0 +1,35 @@ +import {isStatusStillActive} from '#/features/liveNow/utils' + +describe('isStatusStillActive', () => { + const iso = (offsetMs: number) => + new Date(Date.now() + offsetMs).toISOString() + + it('is active for a future expiry', () => { + expect(isStatusStillActive(iso(60_000))).toBe(true) + }) + + it('is not active for a past expiry', () => { + expect(isStatusStillActive(iso(-60_000))).toBe(false) + }) + + it('is not active without a value', () => { + expect(isStatusStillActive(undefined)).toBe(false) + expect(isStatusStillActive('')).toBe(false) + }) + + /* + * The predicate reads an atproto `datetime`, which always carries an offset. + * An unparseable value yields NaN, and every NaN comparison is false, so a + * bad timestamp reads as "not live" rather than throwing or showing a stale + * live badge. + */ + it('is not active for an unparseable value', () => { + expect(isStatusStillActive('not-a-date')).toBe(false) + }) + + it('handles offsets and fractional seconds', () => { + const future = new Date(Date.now() + 3_600_000) + expect(isStatusStillActive(future.toISOString())).toBe(true) + expect(isStatusStillActive('2000-01-01T00:00:00.123+00:00')).toBe(false) + }) +}) diff --git a/src/features/liveNow/utils.ts b/src/features/liveNow/utils.ts index a4fb0bbf62..47e4398ea1 100644 --- a/src/features/liveNow/utils.ts +++ b/src/features/liveNow/utils.ts @@ -97,3 +97,16 @@ export function getLiveNowHost(url: string) { const {hostname} = new URL(url) return sanitizeLiveNowHost(hostname) } + +/** + * Whether a live status has not yet expired. + * + * Called per post, per render while scrolling, so this stays on primitives - + * `parseISO` plus two `Date` objects showed up in the scroll profile. + * `Date.parse` returns `NaN` for an unparseable value and `NaN > n` is `false`, + * which is the same "not active" answer the date-fns version gave. + */ +export function isStatusStillActive(timeStr: string | undefined) { + if (!timeStr) return false + return Date.parse(timeStr) > Date.now() +}