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
15 changes: 12 additions & 3 deletions resources/js/components/product-hunt-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ export function ProductHuntBanner() {
const [now, setNow] = useState(() => Date.now());

useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000);
if (now >= LAUNCH_ENDS_AT) {
return;
}

return () => clearInterval(interval);
}, []);
const nextChange =
now < LAUNCH_STARTS_AT ? LAUNCH_STARTS_AT : LAUNCH_ENDS_AT;
const timeout = setTimeout(
() => setNow(Date.now()),
Math.min(2_147_483_647, Math.max(0, nextChange - Date.now())),
);

return () => clearTimeout(timeout);
}, [now]);

if (now < LAUNCH_STARTS_AT || now >= LAUNCH_ENDS_AT) {
return null;
Expand Down
14 changes: 13 additions & 1 deletion resources/js/layouts/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ export default function AppLayout({ children }: AppLayoutProps) {
const showShortcuts = useCallback(() => setIsShowingShortcuts(true), []);
const openSearch = useCallback(() => setIsSearching(true), []);
const isGuest = auth.user === null;
// Public feeds and documents have no unread counts, and these routes cannot be deleted.
const skipCountPolling =
isGuest &&
[
'log/show',
'about/show',
'brand/show',
'changelog/show',
'privacy/show',
'search/index',
'terms/show',
].includes(component);
const isMessages = component.startsWith('conversation/');
const hasComposer = component === 'log/show' && !isGuest;
const isComposerInView = useIsComposerInView();
Expand Down Expand Up @@ -106,7 +118,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
data-nav={isLayoutOpen ? 'expanded' : 'collapsed'}
className="mx-auto flex min-h-svh w-full max-w-[1264px] justify-center"
>
{deletedPage === null && (
{deletedPage === null && !skipCountPolling && (
<NotificationCountPolling
isInbox={component === 'notification/index'}
onNotFound={markPageDeleted}
Expand Down
79 changes: 79 additions & 0 deletions tests/Unit/ProductHuntBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// <reference types="node" />
import assert from 'node:assert/strict';
import * as React from 'react';
import { afterAll, afterEach, test, vi } from 'vite-plus/test';

const updates = vi.fn();
const cleanups: (() => void)[] = [];

vi.doMock('react', () => ({
...React,
useState: (getInitial: () => number) => [getInitial(), updates],
useEffect: (effect: () => (() => void) | undefined) => {
const cleanup = effect();
if (cleanup) cleanups.push(cleanup);
},
}));

const { ProductHuntBanner } = await import(
'../../resources/js/components/product-hunt-banner'
);

afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
vi.useRealTimers();
updates.mockClear();
});
afterAll(() => vi.doUnmock('react'));

test('expired launch does not leave a one-second timer running', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-25T12:00:00Z'));

assert.equal(ProductHuntBanner(), null);
assert.equal(vi.getTimerCount(), 0);
});

test('before launch, schedules only the launch start', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-19T12:00:00Z'));

assert.equal(ProductHuntBanner(), null);
assert.equal(vi.getTimerCount(), 1);

vi.advanceTimersByTime(1_000);
assert.equal(updates.mock.calls.length, 0);

vi.advanceTimersToNextTimer();
assert.equal(updates.mock.calls.length, 1);
});

test('during launch, schedules only the launch end', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-20T12:00:00Z'));

assert.notEqual(ProductHuntBanner(), null);
assert.equal(vi.getTimerCount(), 1);

vi.advanceTimersByTime(1_000);
assert.equal(updates.mock.calls.length, 0);

vi.advanceTimersToNextTimer();
assert.equal(updates.mock.calls.length, 1);
});

test('a clock far before launch does not overflow the timeout delay', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-01T12:00:00Z'));
const start = Date.now();

assert.equal(ProductHuntBanner(), null);
assert.equal(vi.getTimerCount(), 1);

vi.advanceTimersByTime(1_000);
assert.equal(updates.mock.calls.length, 0);

vi.advanceTimersToNextTimer();
assert.equal(updates.mock.calls.length, 1);
assert.equal(updates.mock.calls[0]?.[0], start + 2_147_483_647);
});