diff --git a/packages/shared/src/features/interests/hooks/useShareAgent.ts b/packages/shared/src/features/interests/hooks/useShareAgent.ts index 2858e4c5b53..15b9128d45e 100644 --- a/packages/shared/src/features/interests/hooks/useShareAgent.ts +++ b/packages/shared/src/features/interests/hooks/useShareAgent.ts @@ -1,17 +1,11 @@ import { useState } from 'react'; import { useShareOrCopyLink } from '../../../hooks/useShareOrCopyLink'; -import { webappUrl } from '../../../lib/constants'; +import { getAbsoluteWebappUrl } from '../../../lib/links'; import { ReferralCampaignKey } from '../../../lib/referral'; import type { UserInterest } from '../../../graphql/interests'; -export const agentShareLink = (query: string): string => { - const path = `${webappUrl}agent?q=${encodeURIComponent(query)}`; - // Must be absolute: the share pipeline runs `new URL(link)`, and `webappUrl` - // is a bare path in development, which threw rather than degrading. - const origin = globalThis?.location?.origin; - - return origin ? new URL(path, origin).toString() : path; -}; +export const agentShareLink = (query: string): string => + getAbsoluteWebappUrl(`agent?q=${encodeURIComponent(query)}`); export const useShareAgent = ( interest?: Pick, diff --git a/packages/shared/src/hooks/useSourceMenuProps.tsx b/packages/shared/src/hooks/useSourceMenuProps.tsx index 1a25938348b..3114cf04d77 100644 --- a/packages/shared/src/hooks/useSourceMenuProps.tsx +++ b/packages/shared/src/hooks/useSourceMenuProps.tsx @@ -1,6 +1,7 @@ import { useRouter } from 'next/router'; import { useContentPreference } from './contentPreference/useContentPreference'; import { webappUrl } from '../lib/constants'; +import { getAbsoluteWebappUrl } from '../lib/links'; import type { SourceTooltip } from '../graphql/sources'; import { ContentPreferenceType } from '../graphql/contentPreference'; import { LogEvent } from '../lib/log'; @@ -58,7 +59,7 @@ const useSourceMenuProps = ({ text: source?.handle ? `Check out ${source.handle} on daily.dev` : 'Check out this source on daily.dev', - link: source?.permalink || webappUrl, + link: source?.permalink || getAbsoluteWebappUrl(), cid: ReferralCampaignKey.ShareSource, logObject: () => ({ event_name: LogEvent.ShareSource, diff --git a/packages/shared/src/lib/links.spec.ts b/packages/shared/src/lib/links.spec.ts index 492882b291a..b72e9647680 100644 --- a/packages/shared/src/lib/links.spec.ts +++ b/packages/shared/src/lib/links.spec.ts @@ -1,4 +1,4 @@ -import { getPathnameWithQuery, withHttps } from './links'; +import { getAbsoluteWebappUrl, getPathnameWithQuery, withHttps } from './links'; describe('lib/links tests', () => { it('should return links as https links', () => { @@ -51,3 +51,34 @@ describe('getPathnameWithQuery', () => { expect(getPathnameWithQuery('/foo? ', 'a=1')).toBe('/foo?a=1'); }); }); + +describe('getAbsoluteWebappUrl', () => { + // The test setup mirrors the webapp, where `webappUrl` is a bare `/`. + it('resolves the path against the page origin', () => { + expect(getAbsoluteWebappUrl('tools/docker')).toBe( + `${globalThis.location.origin}/tools/docker`, + ); + }); + + it('points at the home page without a path', () => { + expect(getAbsoluteWebappUrl()).toBe(`${globalThis.location.origin}/`); + }); + + it('leaves an absolute webappUrl, as on the extension, alone', () => { + const previous = process.env.NEXT_PUBLIC_WEBAPP_URL; + process.env.NEXT_PUBLIC_WEBAPP_URL = 'https://app.daily.dev/'; + + try { + jest.isolateModules(() => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const links = require('./links'); + + expect(links.getAbsoluteWebappUrl('world/ido')).toBe( + 'https://app.daily.dev/world/ido', + ); + }); + } finally { + process.env.NEXT_PUBLIC_WEBAPP_URL = previous; + } + }); +}); diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index fb802ebe610..071608e5e23 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -137,6 +137,16 @@ export const getPathnameWithQuery = ( export const toWebappHref = (path: string): string => path.startsWith('/') ? `${webappUrl}${path.slice(1)}` : path; +// For links that leave the tab (share, copy): `webappUrl` is a bare `/` on the +// webapp, and the share pipeline runs `new URL(link)` on whatever it is handed. +// An absolute `webappUrl` (the extension) passes through unchanged. +export const getAbsoluteWebappUrl = (path = ''): string => { + const url = `${webappUrl}${path}`; + const origin = globalThis?.location?.origin; + + return origin ? new URL(url, origin).toString() : url; +}; + export const agentsHighlightsPath = '/highlights/vibes'; export const agentsHighlightsUrl = `${webappUrl}${agentsHighlightsPath.slice( diff --git a/packages/shared/src/lib/referral.ts b/packages/shared/src/lib/referral.ts index 49ba9fbf329..f1adf323088 100644 --- a/packages/shared/src/lib/referral.ts +++ b/packages/shared/src/lib/referral.ts @@ -8,4 +8,6 @@ export enum ReferralCampaignKey { ShareTag = 'share_tag', ShareAgent = 'share_agent', ShareSlack = 'share_slack', + ShareWorld = 'share_world', + ShareTool = 'share_tool', } diff --git a/packages/webapp/__tests__/ToolPage.spec.tsx b/packages/webapp/__tests__/ToolPage.spec.tsx index cfd0e45e823..fb3d83e82d1 100644 --- a/packages/webapp/__tests__/ToolPage.spec.tsx +++ b/packages/webapp/__tests__/ToolPage.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import nock from 'nock'; import type { NextRouter } from 'next/router'; import type { RenderResult } from '@testing-library/react'; -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; @@ -269,3 +269,19 @@ it('should render squads with the directory card details', async () => { expect(screen.getByText(/@platform/)).toBeInTheDocument(); expect(screen.getByText('42 members')).toBeInTheDocument(); }); + +it('should copy an absolute, tracked link to the tool', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + renderComponent(defaultProps, loggedUser); + + const share = await screen.findByRole('button', { name: 'Share' }); + await act(async () => { + fireEvent.click(share); + }); + + // `webappUrl` is a bare `/` on the webapp, which pastes as a dead link. + expect(writeText).toHaveBeenCalledWith( + `${globalThis.location.origin}/tools/docker?userid=${loggedUser.id}&cid=share_tool`, + ); +}); diff --git a/packages/webapp/__tests__/WorldShare.spec.tsx b/packages/webapp/__tests__/WorldShare.spec.tsx index 689fb5d7414..8a4a041ba37 100644 --- a/packages/webapp/__tests__/WorldShare.spec.tsx +++ b/packages/webapp/__tests__/WorldShare.spec.tsx @@ -1,10 +1,18 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { fireEvent, render as rtlRender, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render as rtlRender, + screen, +} from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PublicProfile } from '@dailydotdev/shared/src/lib/user'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; import { WorldShare } from '../components/world/WorldShare'; const mockShareOrCopy = jest.fn(); @@ -14,6 +22,10 @@ jest.mock('@dailydotdev/shared/src/hooks/useShareOrCopyLink', () => ({ useShareOrCopyLink: (props: unknown) => mockUseShareOrCopyLink(props), })); +const { useShareOrCopyLink: actualUseShareOrCopyLink } = jest.requireActual( + '@dailydotdev/shared/src/hooks/useShareOrCopyLink', +); + const user = { id: 'u1', username: 'ido', @@ -23,6 +35,7 @@ const user = { interface ShareArgs { link: string; text: string; + cid?: ReferralCampaignKey; logObject: (provider: ShareProvider) => Record; } @@ -45,10 +58,9 @@ describe('WorldShare', () => { it('hands out an absolute link to the world', () => { render(); - // Absolute on purpose: the whole point is a link that survives the tab. - expect(argsOf().link).toBe( - `${process.env.NEXT_PUBLIC_WEBAPP_URL}world/ido`, - ); + // Absolute on purpose: the whole point is a link that survives the tab, + // and `webappUrl` is a bare `/` on the webapp. + expect(argsOf().link).toBe(`${globalThis.location.origin}/world/ido`); }); it('falls back to the id for a reader with no username', () => { @@ -56,7 +68,32 @@ describe('WorldShare', () => { , ); - expect(argsOf().link).toBe(`${process.env.NEXT_PUBLIC_WEBAPP_URL}world/u1`); + expect(argsOf().link).toBe(`${globalThis.location.origin}/world/u1`); + }); + + it('tags the link with the world share campaign', () => { + render(); + + expect(argsOf().cid).toBe(ReferralCampaignKey.ShareWorld); + }); + + it('copies a link that still works once pasted', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + mockUseShareOrCopyLink.mockImplementation(actualUseShareOrCopyLink); + rtlRender( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Share this world' })); + }); + + expect(writeText).toHaveBeenCalledWith( + `${globalThis.location.origin}/world/ido?userid=${loggedUser.id}&cid=share_world`, + ); }); it('names the owner when the world belongs to somebody else', () => { diff --git a/packages/webapp/components/world/WorldShare.tsx b/packages/webapp/components/world/WorldShare.tsx index 5871d76ea24..77f8ae9690a 100644 --- a/packages/webapp/components/world/WorldShare.tsx +++ b/packages/webapp/components/world/WorldShare.tsx @@ -9,8 +9,9 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; -import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { getAbsoluteWebappUrl } from '@dailydotdev/shared/src/lib/links'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; import type { ShareProvider } from '@dailydotdev/shared/src/lib/share'; interface WorldShareProps { @@ -43,7 +44,7 @@ export function WorldShare({ }: WorldShareProps): ReactElement { const whose = isOwn ? 'my' : `${user.name}'s`; const [copying, onShareOrCopy] = useShareOrCopyLink({ - link: `${webappUrl}world/${user.username || user.id}`, + link: getAbsoluteWebappUrl(`world/${user.username || user.id}`), text: worldName ? `Check out ${worldName}, ${whose} world on daily.dev` : `Check out ${whose} world on daily.dev`, @@ -52,6 +53,7 @@ export function WorldShare({ target_id: user.id, extra: JSON.stringify({ provider }), }), + cid: ReferralCampaignKey.ShareWorld, }); return ( diff --git a/packages/webapp/pages/join/index.tsx b/packages/webapp/pages/join/index.tsx index d139df6df88..72250038656 100644 --- a/packages/webapp/pages/join/index.tsx +++ b/packages/webapp/pages/join/index.tsx @@ -29,6 +29,8 @@ const componentsMap: ReferralRecord> = { [ReferralCampaignKey.ShareTag]: Referral, [ReferralCampaignKey.ShareAgent]: Referral, [ReferralCampaignKey.ShareSlack]: Referral, + [ReferralCampaignKey.ShareWorld]: Referral, + [ReferralCampaignKey.ShareTool]: Referral, }; const referralCampaignValues = new Set( diff --git a/packages/webapp/pages/tools/[slug].tsx b/packages/webapp/pages/tools/[slug].tsx index e33850d725b..fd55d9ff688 100644 --- a/packages/webapp/pages/tools/[slug].tsx +++ b/packages/webapp/pages/tools/[slug].tsx @@ -91,8 +91,10 @@ import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopy import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; import { publishTimeRelativeShort } from '@dailydotdev/shared/src/lib/dateFormat'; -import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; -import { getDomainFromUrl } from '@dailydotdev/shared/src/lib/links'; +import { + getAbsoluteWebappUrl, + getDomainFromUrl, +} from '@dailydotdev/shared/src/lib/links'; import { ProfileImageSize, ProfilePicture, @@ -101,6 +103,7 @@ import { ProfilePictureGroup } from '@dailydotdev/shared/src/components/ProfileP import { ToolLogo } from '@dailydotdev/shared/src/components/tools/ToolLogo'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts'; import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; import { TAG_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed'; @@ -356,13 +359,14 @@ const ToolPage = ({ ); const [copying, onShareOrCopy] = useShareOrCopyLink({ - link: `${webappUrl}tools/${tool.slug}`, + link: getAbsoluteWebappUrl(`tools/${tool.slug}`), text: `Check out ${tool.title} on daily.dev`, logObject: (provider) => ({ event_name: LogEvent.ShareTool, target_id: tool.slug, extra: JSON.stringify({ provider, origin: Origin.ToolPage }), }), + cid: ReferralCampaignKey.ShareTool, }); const { data: followedStackers } = useQuery({