Skip to content
Merged
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
32 changes: 32 additions & 0 deletions packages/shared/src/graphql/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,22 @@ export const TRACK_QUEST_EVENT_MUTATION = gql`
}
`;

export const TRACK_SHARED_POST_CLICK_MUTATION = gql`
mutation TrackSharedPostClick(
$referringUserId: ID!
$postId: ID!
$campaign: String!
) {
trackSharedPostClick(
referringUserId: $referringUserId
postId: $postId
campaign: $campaign
) {
_
}
}
`;

export const QUEST_UPDATE_SUBSCRIPTION = gql`
subscription QuestUpdate {
questUpdate {
Expand All @@ -434,3 +450,19 @@ export const trackQuestClientEvent = async (
): Promise<void> => {
await gqlClient.request(TRACK_QUEST_EVENT_MUTATION, { eventType });
};

export const trackSharedPostClick = async ({
referringUserId,
postId,
campaign,
}: {
referringUserId: string;
postId: string;
campaign: string;
}): Promise<void> => {
await gqlClient.request(TRACK_SHARED_POST_CLICK_MUTATION, {
referringUserId,
postId,
campaign,
});
};
1 change: 1 addition & 0 deletions packages/shared/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export * from './useBookmarkProvider';
export * from './usePlusSubscription';
export * from './useClaimQuestReward';
export * from './useQuestDashboard';
export * from './useShareLinkClick';
export * from './onboarding/useCheckExistingEmail';
export * from './onboarding/useGenerateUsername';
export * from './post/useBlockPostPanel';
Expand Down
204 changes: 204 additions & 0 deletions packages/shared/src/hooks/useShareLinkClick.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { renderHook, waitFor } from '@testing-library/react';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import { useAuthContext } from '../contexts/AuthContext';
import { trackSharedPostClick } from '../graphql/quests';
import { ReferralCampaignKey } from '../lib/referral';
import {
getShareLinkClickKey,
isShareLinkClickCampaign,
shouldTrackShareLinkClick,
useShareLinkClick,
} from './useShareLinkClick';

jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));

jest.mock('../contexts/AuthContext', () => ({
useAuthContext: jest.fn(),
}));

jest.mock('../graphql/quests', () => ({
...jest.requireActual('../graphql/quests'),
trackSharedPostClick: jest.fn(),
}));

const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
const mockUseAuthContext = useAuthContext as jest.MockedFunction<
typeof useAuthContext
>;
const mockTrackSharedPostClick = trackSharedPostClick as jest.MockedFunction<
typeof trackSharedPostClick
>;

const postId = 'post-1';
const referringUserId = 'sharer-1';

const setRouterQuery = (query: NextRouter['query']) => {
mockUseRouter.mockReturnValue({
query,
} as unknown as NextRouter);
};

const setViewer = ({
userId,
isAuthReady = true,
}: {
userId?: string | null;
isAuthReady?: boolean;
} = {}) => {
mockUseAuthContext.mockReturnValue({
user: userId ? { id: userId } : null,
isAuthReady,
} as unknown as ReturnType<typeof useAuthContext>);
};

describe('share link click helpers', () => {
it('should identify click campaigns and build stable keys', () => {
expect(isShareLinkClickCampaign(ReferralCampaignKey.SharePost)).toBe(true);
expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareSlack)).toBe(true);
expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareComment)).toBe(
false,
);
expect(
getShareLinkClickKey({
referringUserId,
postId,
campaign: ReferralCampaignKey.SharePost,
}),
).toBe(`${referringUserId}:${postId}:${ReferralCampaignKey.SharePost}`);
});

it('should reject incomplete and self-click attribution', () => {
expect(
shouldTrackShareLinkClick({
campaign: ReferralCampaignKey.SharePost,
referringUserId,
postId,
userId: 'visitor-1',
}),
).toBe(true);
expect(
shouldTrackShareLinkClick({
campaign: ReferralCampaignKey.SharePost,
referringUserId,
postId,
userId: referringUserId,
}),
).toBe(false);
expect(
shouldTrackShareLinkClick({
campaign: ReferralCampaignKey.ShareProfile,
referringUserId,
postId,
}),
).toBe(false);
expect(
shouldTrackShareLinkClick({
campaign: ReferralCampaignKey.SharePost,
postId,
}),
).toBe(false);
});
});

describe('useShareLinkClick', () => {
beforeEach(() => {
jest.clearAllMocks();
setViewer();
setRouterQuery({
cid: ReferralCampaignKey.SharePost,
userid: referringUserId,
});
mockTrackSharedPostClick.mockResolvedValue(undefined);
});

it('should track an eligible anonymous post share click once', async () => {
const { rerender } = renderHook(
(props: { postId?: string }) => useShareLinkClick(props),
{
initialProps: { postId },
},
);

await waitFor(() => {
expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});

expect(mockTrackSharedPostClick).toHaveBeenCalledWith({
referringUserId,
postId,
campaign: ReferralCampaignKey.SharePost,
});

rerender({ postId });

expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});

it('should use the first query param value and ignore unrelated route changes', async () => {
const { rerender } = renderHook(
(props: { postId?: string }) => useShareLinkClick(props),
{
initialProps: { postId },
},
);

await waitFor(() => {
expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});

setRouterQuery({
cid: [ReferralCampaignKey.SharePost, ReferralCampaignKey.ShareProfile],
userid: [referringUserId, 'other-sharer'],
unrelated: '1',
});
rerender({ postId });

expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});

it('should skip self-clicks', () => {
setViewer({ userId: referringUserId });

renderHook(() => useShareLinkClick({ postId }));

expect(mockTrackSharedPostClick).not.toHaveBeenCalled();
});

it('should skip when attribution is incomplete or auth is not ready', () => {
setRouterQuery({ userid: referringUserId });
renderHook(() => useShareLinkClick({ postId }));

setRouterQuery({
cid: ReferralCampaignKey.SharePost,
userid: referringUserId,
});
renderHook(() => useShareLinkClick({}));

setViewer({ isAuthReady: false });
renderHook(() => useShareLinkClick({ postId }));

expect(mockTrackSharedPostClick).not.toHaveBeenCalled();
});

it('should not retry after a failed tracking request on rerender', async () => {
mockTrackSharedPostClick.mockRejectedValue(new Error('network error'));

const { rerender } = renderHook(
(props: { postId?: string }) => useShareLinkClick(props),
{
initialProps: { postId },
},
);

await waitFor(() => {
expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});

rerender({ postId });

expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1);
});
});
89 changes: 89 additions & 0 deletions packages/shared/src/hooks/useShareLinkClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import { useAuthContext } from '../contexts/AuthContext';
import { trackSharedPostClick } from '../graphql/quests';
import { getFirstQueryParam } from '../lib/func';
import { ReferralCampaignKey } from '../lib/referral';

const SHARE_LINK_CLICK_CAMPAIGNS = new Set<string>([
ReferralCampaignKey.SharePost,
ReferralCampaignKey.ShareSlack,
]);

export const isShareLinkClickCampaign = (
campaign?: string | null,
): campaign is ReferralCampaignKey =>
!!campaign && SHARE_LINK_CLICK_CAMPAIGNS.has(campaign);

interface UseShareLinkClickProps {
postId?: string | null;
enabled?: boolean;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: enabled has no caller — the only mount passes postId alone — and no test covers it. Please drop it until a surface actually needs to switch the hook off.

Reviewed by AI.

}

export const getShareLinkClickKey = ({
referringUserId,
postId,
campaign,
}: {
referringUserId: string;
postId: string;
campaign: string;
}): string => `${referringUserId}:${postId}:${campaign}`;

export const shouldTrackShareLinkClick = ({
campaign,
referringUserId,
postId,
userId,
}: {
campaign?: string | null;
referringUserId?: string | null;
postId?: string | null;
userId?: string | null;
}): boolean =>
isShareLinkClickCampaign(campaign) &&
!!referringUserId &&
!!postId &&
referringUserId !== userId;

export const useShareLinkClick = ({
postId,
enabled = true,
}: UseShareLinkClickProps): void => {
const { user, isAuthReady } = useAuthContext();
const router = useRouter();
const trackedKeysRef = useRef(new Set<string>());
const campaign = getFirstQueryParam(router.query.cid);
const referringUserId = getFirstQueryParam(router.query.userid);

useEffect(() => {
if (
!enabled ||
!isAuthReady ||
!isShareLinkClickCampaign(campaign) ||
!referringUserId ||
!postId ||
referringUserId === user?.id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: shouldTrackShareLinkClick is exported and unit-tested, but the hook re-implements the same four conditions inline instead of calling it. The two copies can drift, and the tests would then be asserting a predicate the hook does not use. Call shouldTrackShareLinkClick({ campaign, referringUserId, postId, userId: user?.id }) here and keep enabled/isAuthReady as the only extra guards.

Reviewed by AI.

) {
return;
}

const clickKey = getShareLinkClickKey({
referringUserId,
postId,
campaign,
});

if (trackedKeysRef.current.has(clickKey)) {
return;
}

trackedKeysRef.current.add(clickKey);

trackSharedPostClick({
referringUserId,
postId,
campaign,
}).catch(() => undefined);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: trackedKeysRef only lives for the mount, so a refresh or a re-entry to the same shared post fires the mutation again. Server-side that is deduped per UTC day, but it is charged against the caller's 20/60s rate-limit bucket on the API, so a visitor reloading a shared link a few times can exhaust their own budget and have a later genuine click rejected. Persisting the click key (sessionStorage) would keep the retry-free behaviour across mounts.

Reviewed by AI.

}, [campaign, enabled, isAuthReady, postId, referringUserId, user?.id]);
};
Loading
Loading