From 1d278d455e5748321593489622f24bd6517230e1 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 18:01:26 +0300 Subject: [PATCH 01/17] feat(share): add a reusable copy link button Wraps useShareOrCopyLink so a surface can offer a link directly instead of burying it in an overflow menu: the native sheet on mobile, a clipboard copy on desktop. The hook already raises a toast on copy, so the button stays static rather than swapping in a confirmation icon. Defaults to Float because it usually sits beside a Block or overflow button; surfaces whose neighbours are tertiary pass their own variant. --- .../src/components/share/CopyLinkButton.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/shared/src/components/share/CopyLinkButton.tsx diff --git a/packages/shared/src/components/share/CopyLinkButton.tsx b/packages/shared/src/components/share/CopyLinkButton.tsx new file mode 100644 index 0000000000..c81e483086 --- /dev/null +++ b/packages/shared/src/components/share/CopyLinkButton.tsx @@ -0,0 +1,38 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { LinkIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import type { UseShareOrCopyLinkProps } from '../../hooks/useShareOrCopyLink'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; + +interface CopyLinkButtonProps { + shareProps: UseShareOrCopyLinkProps; + className?: string; + size?: ButtonSize; + variant?: ButtonVariant; +} + +const LABEL = 'Copy link'; + +export const CopyLinkButton = ({ + shareProps, + className, + size = ButtonSize.Small, + variant = ButtonVariant.Float, +}: CopyLinkButtonProps): ReactElement => { + const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); + + return ( + + )} + push( @@ -469,15 +481,7 @@ export const TagTopicPage = ({ feedId, }) } - shareProps={{ - text: `Check out the ${tag} tag on daily.dev`, - link: globalThis?.location?.href, - cid: ReferralCampaignKey.ShareTag, - logObject: () => ({ - event_name: LogEvent.ShareTag, - target_id: tag, - }), - }} + shareProps={shareProps} /> {/* SEO crawl paths preserved from the legacy tag page. */} diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index baab4eadec..4a48fc4e6f 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -301,7 +301,7 @@ const SourcePage = ({

{source.name}

- +
{source?.description && (

{source?.description}

From 013296370356d12a709c215ad4c8e337f768a89d Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 18:01:41 +0300 Subject: [PATCH 03/17] docs(snapshot): document the topic and directory page share placements One story covering the four surfaces, each showing the placement that ships across desktop, tablet and mobile, so the layouts can be compared side by side without opening four pages. Mockup-to-eng-pass: 1 --- .../features/snapshot/surfaceChrome.tsx | 162 +++++++++ .../snapshot/surfaces/Directories.stories.tsx | 323 ++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 0000000000..d4654bca9f --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { LinkIcon } from '@dailydotdev/shared/src/components/icons'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + + +/* ------------------------------------------------------------------ prose */ + +export const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +/** + * Inert on purpose: this page compares where a control sits inside a real + * screen. The working buttons and live capture are on Button placements. + */ +export const Control = () => ( + + + + + + + +); + +/* ---------------------------------------------------------------- sources */ + +/** The source page is left-aligned, and its actions sit below the title. */ +const SourceScreen = ({ device }: { device: DeviceName }) => ( + +
+ + Sources / XDA Developers + + +
+ +

+ XDA Developers +

+
+ +
+ +
+ +

+ News and reviews for developers, by developers. +

+ +
+ {['#android', '#hardware', '#reviews'].map((tag) => ( + + {tag} + + ))} +
+
+
+); + +/* ----------------------------------------------------------------- squads */ + +/** SquadEntityCard: w-80, image and actions on one row, body under it. */ +const SquadCard = () => ( +
+
+ +
+ + + {/* SquadHeaderMenu — `invisible group-hover/menu:visible`. */} + +
+
+
+ + Frontend Fans + +

+ Everything CSS, React and the browser. Ship it and show it. +

+ + 2.4K Members + · + 12K Upvotes + +
+
+); + +const SquadScreen = ({ device }: { device: DeviceName }) => ( + +
+

Squads

+
+ + {device === 'Desktop' && } +
+
+
+); + +/* ---------------------------------------------------------------- archive */ + +/** ArchiveIndexPage: a month grid, no posts and no feed controls. */ +const ArchiveScreen = ({ device }: { device: DeviceName }) => ( + +
+ + Sources / XDA Developers / Best of + +
+

+ Best of XDA Developers — Archive +

+ +
+ +
+ {['2026', '2025'].map((year) => ( +
+

+ {year} +

+
+ {['January', 'February', 'March', 'April'].map((month) => ( + + + {month} + + + 24 posts + + + ))} +
+
+ ))} +
+
+
+); + +/* -------------------------------------------------------------------- page */ + +const Rails = ({ + Screen, +}: { + Screen: React.ComponentType<{ device: DeviceName }>; +}) => ( + + + + + +); + +const Directories = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Topic & directory pages', + component: Directories, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 51cd869bd3fc6f7aaa6965e997dc46ed7f473ef2 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 12:35:24 +0300 Subject: [PATCH 04/17] feat(share): confirm a copy with the upvote button's arrow The copy link control relied on the toast alone, so a press had no feedback on the button itself. It now swaps to a filled avocado arrow and spins through the same 0.4s curve as the upvote button, then reverts when useCopy clears its 1s window. Co-Authored-By: Claude Opus 5 --- .../src/components/share/CopyLinkButton.tsx | 19 +++++++++++++++---- packages/shared/tailwind.config.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/share/CopyLinkButton.tsx b/packages/shared/src/components/share/CopyLinkButton.tsx index c81e483086..826afa4c15 100644 --- a/packages/shared/src/components/share/CopyLinkButton.tsx +++ b/packages/shared/src/components/share/CopyLinkButton.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { LinkIcon } from '../icons'; +import { LinkIcon, UpvoteIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import type { UseShareOrCopyLinkProps } from '../../hooks/useShareOrCopyLink'; import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; @@ -14,6 +14,7 @@ interface CopyLinkButtonProps { } const LABEL = 'Copy link'; +const COPIED_LABEL = 'Copied!'; export const CopyLinkButton = ({ shareProps, @@ -21,14 +22,24 @@ export const CopyLinkButton = ({ size = ButtonSize.Small, variant = ButtonVariant.Float, }: CopyLinkButtonProps): ReactElement => { - const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps); + // Stays false on the native-share path, where the OS sheet is the feedback. + const [copied, onShareOrCopyLink] = useShareOrCopyLink(shareProps); return ( - + + + + + +); + +/** UnfeaturedSquadGrid: logo and Join on one row, body beneath. */ +const DirectorySquadCard = () => ( +
+
+
- - {/* SquadHeaderMenu — `invisible group-hover/menu:visible`. */} -
-
- - Frontend Fans - -

- Everything CSS, React and the browser. Ship it and show it. -

- - 2.4K Members - · - 12K Upvotes - -
+ Learn Python +

+ Welcome to the Learn Python community. +

+ + @lpython · 27.4K members +
); const SquadScreen = ({ device }: { device: DeviceName }) => (
-

Squads

+

Featured

+
+ + {device === 'Desktop' && } +
+

Languages

- - {device === 'Desktop' && } + + {device === 'Desktop' && } +
+
+
+); + +/* ----------------------------------------------------- squad page & rows */ + +/** SquadHeaderBar: the control joins the icon cluster after the bell. */ +const SquadPageScreen = ({ device }: { device: DeviceName }) => ( + +
+
+ +
+
+ +
+ + TheCoverLikers + + + @thecoverlikers · Created Feb 2023 + +
); +/** SourceTopList: four ranked lists, the control revealed per row on hover. */ +const SourceRowsScreen = ({ device }: { device: DeviceName }) => ( + +
+

+ Trending sources +

+ {['Joud Awad', 'Work Chronicles', 'Appwrite'].map((name, index) => ( +
+ + {index + 1} + + + + {name} + + {index === 0 && } +
+ ))} +
+
+); + /* ---------------------------------------------------------------- archive */ /** ArchiveIndexPage: a month grid, no posts and no feed controls. */ @@ -250,8 +345,8 @@ const Rails = ({ const Directories = () => ( ( + + + + + + + + + + + + Date: Thu, 10 Sep 2026 15:27:34 +0300 Subject: [PATCH 10/17] fix(share): log the provider and placement of every copy link The copy link placements logged ShareTag/ShareSource with only a target, so a press on the new button could not be told apart from the overflow menu's Share item on the same page, and native share could not be told apart from a copy. CopyLinkButton now takes a required origin and adds extra: { provider, origin } to the caller's event, the same extra shape #6556 uses for SharePost. Tag, source and squad pages reuse their page origins; the source directory lists and the best-of archive get new SourceDirectory and ArchiveIndex values. The global best-of archive has no tag or source, but fell through to ShareSource with no target and the share_source campaign. It now logs a new ShareArchive event with the generic campaign. Also imports LinkIcon from its own file rather than the icons barrel. --- .../components/archive/ArchiveIndexPage.tsx | 27 ++++++++++++++----- .../cards/Leaderboard/SourceTopList.tsx | 3 ++- .../src/components/cards/squad/SquadGrid.tsx | 1 + .../cards/squad/UnfeaturedSquadGrid.tsx | 1 + .../src/components/share/CopyLinkButton.tsx | 16 ++++++++--- .../sources/SourceActions/index.tsx | 6 +++-- .../src/components/squads/SquadHeaderBar.tsx | 1 + .../src/components/tags/TagTopicPage.tsx | 2 +- packages/shared/src/lib/log.ts | 3 +++ 9 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/archive/ArchiveIndexPage.tsx b/packages/shared/src/components/archive/ArchiveIndexPage.tsx index f1e6f77dce..7537ff3c78 100644 --- a/packages/shared/src/components/archive/ArchiveIndexPage.tsx +++ b/packages/shared/src/components/archive/ArchiveIndexPage.tsx @@ -15,7 +15,7 @@ import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; import { ElementPlaceholder } from '../ElementPlaceholder'; import { CopyLinkButton } from '../share/CopyLinkButton'; -import { LogEvent } from '../../lib/log'; +import { LogEvent, Origin } from '../../lib/log'; import { ReferralCampaignKey } from '../../lib/referral'; interface ArchiveIndexPageProps { @@ -154,6 +154,24 @@ function ArchiveGrid({ ); } +const shareByScope: Record< + ArchiveScopeInfo['scopeType'], + { event: LogEvent; cid: ReferralCampaignKey } +> = { + [ArchiveScopeType.Global]: { + event: LogEvent.ShareArchive, + cid: ReferralCampaignKey.Generic, + }, + [ArchiveScopeType.Tag]: { + event: LogEvent.ShareTag, + cid: ReferralCampaignKey.ShareTag, + }, + [ArchiveScopeType.Source]: { + event: LogEvent.ShareSource, + cid: ReferralCampaignKey.ShareSource, + }, +}; + export function ArchiveIndexPage({ archives, scopeType, @@ -163,11 +181,7 @@ export function ArchiveIndexPage({ className, }: ArchiveIndexPageProps): ReactElement { const groups = groupArchivesByYear(archives); - const isTagScope = scopeType === ArchiveScopeType.Tag; - const shareCampaign = isTagScope - ? ReferralCampaignKey.ShareTag - : ReferralCampaignKey.ShareSource; - const shareEvent = isTagScope ? LogEvent.ShareTag : LogEvent.ShareSource; + const { event: shareEvent, cid: shareCampaign } = shareByScope[scopeType]; return (
@@ -177,6 +191,7 @@ export function ArchiveIndexPage({ Best of {scopeName} — Archive >; + origin: Origin; className?: string; size?: ButtonSize; variant?: ButtonVariant; @@ -19,12 +22,19 @@ const COPIED_LABEL = 'Copied!'; export const CopyLinkButton = ({ shareProps, + origin, className, size = ButtonSize.Small, variant = ButtonVariant.Float, }: CopyLinkButtonProps): ReactElement => { // Stays false on the native-share path, where the OS sheet is the feedback. - const [copied, onShareOrCopyLink] = useShareOrCopyLink(shareProps); + const [copied, onShareOrCopyLink] = useShareOrCopyLink({ + ...shareProps, + logObject: (provider) => ({ + ...shareProps.logObject(provider), + extra: JSON.stringify({ provider, origin }), + }), + }); return ( diff --git a/packages/shared/src/components/sources/SourceActions/index.tsx b/packages/shared/src/components/sources/SourceActions/index.tsx index dad7169f13..b03dbbde40 100644 --- a/packages/shared/src/components/sources/SourceActions/index.tsx +++ b/packages/shared/src/components/sources/SourceActions/index.tsx @@ -9,7 +9,7 @@ import SourceActionsBlock from './SourceActionsBlock'; import SourceActionsFollow from './SourceActionsFollow'; import CustomFeedOptionsMenu from '../../CustomFeedOptionsMenu'; import { CopyLinkButton } from '../../share/CopyLinkButton'; -import { LogEvent } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference'; import { ContentPreferenceType } from '../../../graphql/contentPreference'; import type { ContentPreferenceMutation } from '../../../hooks/contentPreference/types'; @@ -104,7 +104,9 @@ export const SourceActions = ({ {...blockProps} /> )} - {showCopyLink && } + {showCopyLink && ( + + )} router.push( diff --git a/packages/shared/src/components/squads/SquadHeaderBar.tsx b/packages/shared/src/components/squads/SquadHeaderBar.tsx index b20066dfbe..4525176d54 100644 --- a/packages/shared/src/components/squads/SquadHeaderBar.tsx +++ b/packages/shared/src/components/squads/SquadHeaderBar.tsx @@ -287,6 +287,7 @@ export function SquadHeaderBar({ )} )} - + push( diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index bf644bf2bb..aa2f72ad92 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -23,6 +23,8 @@ export enum Origin { TagPage = 'tag page', ToolPage = 'tool page', ToolsDirectory = 'tools directory', + SourceDirectory = 'source directory', + ArchiveIndex = 'archive index', Profile = 'profile', PostTags = 'post tags', // squads - start @@ -361,6 +363,7 @@ export enum LogEvent { ShareLog = 'share log', ShareWorld = 'share world', ShareTool = 'share tool', + ShareArchive = 'share archive', // End Share /* Start World `world view` is the denominator and fires whatever happens next, so the From 1af2430ae6364946ffd521ffa84a6452369f193f Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:27:42 +0300 Subject: [PATCH 11/17] fix(share): copy the link inside the click in useShareOrCopyLink The copy path awaited the URL shortener before writing to the clipboard. Safari refuses a clipboard write once the task that handled the click has ended, and useCopy now reports that refusal, so every signed-in copy from CopyLinkButton (and the other callers of this hook, including ShareActions) would end in a "blocked the clipboard" toast. It now writes the tracked link straight away and hands the shortening to useCopyLink's shorten path, which swaps the short link in through a pending ClipboardItem, the same approach #6556 took for the post page. The native share path still resolves the short link first, since the text it shares has to carry it. The CopyLinkButton spec pins the synchronous write and the logged provider and origin. The two squad card tests that only asserted the button exists are dropped: they catch nothing the new spec does not. --- .../components/cards/squad/SquadGrid.spec.tsx | 8 --- .../cards/squad/UnfeaturedSquadGrid.spec.tsx | 8 --- .../components/share/CopyLinkButton.spec.tsx | 65 +++++++++++++++++++ .../shared/src/hooks/useShareOrCopyLink.ts | 12 ++-- 4 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 packages/shared/src/components/share/CopyLinkButton.spec.tsx diff --git a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx index 63f2c55fc4..c264617ffb 100644 --- a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx @@ -192,11 +192,3 @@ it('should render the component with a join squad button', async () => { await waitFor(() => expect(queryCalled).toBeTruthy()); }); }); - -it('should render a copy link control alongside the join button', async () => { - renderComponent(); - - expect( - await screen.findByRole('button', { name: 'Copy link' }), - ).toBeInTheDocument(); -}); diff --git a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx index e651a463ef..e92e18a2af 100644 --- a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx +++ b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx @@ -141,11 +141,3 @@ it('should render the component with a join squad button', async () => { await waitFor(() => expect(queryCalled).toBeTruthy()); }); }); - -it('should render a copy link control alongside the join button', async () => { - renderComponent(); - - expect( - await screen.findByRole('button', { name: 'Copy link' }), - ).toBeInTheDocument(); -}); diff --git a/packages/shared/src/components/share/CopyLinkButton.spec.tsx b/packages/shared/src/components/share/CopyLinkButton.spec.tsx new file mode 100644 index 0000000000..f96b1e38e9 --- /dev/null +++ b/packages/shared/src/components/share/CopyLinkButton.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { CopyLinkButton } from './CopyLinkButton'; +import { LogEvent, Origin } from '../../lib/log'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ShareProvider } from '../../lib/share'; + +const writeText = jest.fn().mockResolvedValue(undefined); +const logEvent = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderComponent = () => + render( + + ({ + event_name: LogEvent.ShareSource, + target_id: 'theverge', + }), + }} + /> + , + ); + +it('writes the tracked link within the click, before the shortener answers', () => { + renderComponent(); + + // No await: Safari refuses a clipboard write once the click task has ended. + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(writeText).toHaveBeenCalledWith( + 'https://app.daily.dev/sources/theverge?userid=u1&cid=share_source', + ); +}); + +it('logs the share with its provider and placement', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareSource, + target_id: 'theverge', + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.SourcePage, + }), + }); +}); diff --git a/packages/shared/src/hooks/useShareOrCopyLink.ts b/packages/shared/src/hooks/useShareOrCopyLink.ts index 0c635e392d..16afe36c36 100644 --- a/packages/shared/src/hooks/useShareOrCopyLink.ts +++ b/packages/shared/src/hooks/useShareOrCopyLink.ts @@ -4,7 +4,7 @@ import { useCopyLink } from './useCopy'; import { ShareProvider } from '../lib/share'; import type { LogEvent } from './log/useLogQueue'; import { useGetShortUrl } from './utils/useGetShortUrl'; -import type { ReferralCampaignKey } from '../lib'; +import type { ReferralCampaignKey } from '../lib/referral'; import { shouldUseNativeShare } from '../lib/func'; export interface UseShareOrCopyLinkProps { @@ -22,10 +22,9 @@ export function useShareOrCopyLink({ }: UseShareOrCopyLinkProps): ReturnType { const { logEvent } = useLogContext(); const [copying, copyLink] = useCopyLink(); - const { getShortUrl } = useGetShortUrl(); + const { getShortUrl, getTrackedUrl } = useGetShortUrl(); const onShareOrCopy: CopyNotifyFunction = async () => { - const shortLink = cid ? await getShortUrl(link, cid) : link; const logShareEvent = (provider: ShareProvider): void => { if (!logObject) { return; @@ -35,6 +34,8 @@ export function useShareOrCopyLink({ }; if (shouldUseNativeShare()) { + const shortLink = cid ? await getShortUrl(link, cid) : link; + try { await navigator.share({ text: `${text}\n${shortLink}`, @@ -45,7 +46,10 @@ export function useShareOrCopyLink({ } } else { logShareEvent(ShareProvider.CopyLink); - copyLink({ link: shortLink }); + copyLink({ + link: cid ? getTrackedUrl(link, cid) : link, + shorten: !!cid, + }); } }; From f1f7543bcbc9912b5096c57ce3d30a69ac81f605 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:28:12 +0300 Subject: [PATCH 12/17] fix(sources): hide the hover-revealed copy links only for a mouse The source directory rows and the unfeatured squad cards hid the copy link from laptop width up and revealed it on hover. A touch device that wide (an iPad in landscape, a touch laptop) has no hover, so the control stayed invisible there. Gating on laptop:mouse:, the pairing the feed cards already use, keeps it visible on any touch screen. It still fades by opacity rather than visibility, so it stays reachable by keyboard. Also imports largeNumberFormat from lib/numberFormat, not the lib barrel, in the file this touched. --- .../shared/src/components/cards/Leaderboard/SourceTopList.tsx | 2 +- .../shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx b/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx index 73f17f1f36..1b52c053b9 100644 --- a/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx @@ -37,7 +37,7 @@ export function SourceTopList({ {/* Hover-revealed only where hover exists; always there on touch. */}
Date: Thu, 10 Sep 2026 15:28:27 +0300 Subject: [PATCH 13/17] chore(storybook): drop the topic and directory page mockup The story drew mock tag, source, squad and archive pages to compare copy link placements. This PR ships those placements on the real pages, so the mockup can only drift from them. The Overview row now points at the live pages, like the post page row does. --- .../snapshot/surfaces/Directories.stories.tsx | 333 ------------------ .../snapshot/surfaces/Overview.stories.tsx | 4 +- 2 files changed, 2 insertions(+), 335 deletions(-) delete mode 100644 packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx deleted file mode 100644 index 042cb0bf4f..0000000000 --- a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - BellIcon, - BlockIcon, - MenuIcon, -} from '@dailydotdev/shared/src/components/icons'; -import type { DeviceName } from '../surfaceChrome'; -import { - AVATAR, - Category, - Control, - Device, - Rail, - SurfacePage, - Variant, -} from '../surfaceChrome'; - -/** CustomFeedOptionsMenu — the same two items on tags, sources and profiles. */ - -const Options = () => ( - - - - -
- - -); - -/* ---------------------------------------------------------------- sources */ - -/** The source page is left-aligned, and its actions sit below the title. */ -const SourceScreen = ({ device }: { device: DeviceName }) => ( - -
- - Sources / XDA Developers - - -
- -

- XDA Developers -

-
- -
- -
- -

- News and reviews for developers, by developers. -

- -
- {['#android', '#hardware', '#reviews'].map((tag) => ( - - {tag} - - ))} -
-
-
-); - -/* ----------------------------------------------------------------- squads */ - -/** SquadEntityCard: w-80, image and actions on one row, body under it. */ -const SquadCard = () => ( -
-
- -
- - - {/* SquadHeaderMenu — `invisible group-hover/menu:visible`. */} -
-
-
- - Frontend Fans - -

- Everything CSS, React and the browser. Ship it and show it. -

- - 2.4K Members - · - 12K Upvotes - -
-
-); - -const SquadScreen = ({ device }: { device: DeviceName }) => ( - -
-

Squads

-
- -
-
-
-); - -/* ---------------------------------------------------------------- archive */ - -/** ArchiveIndexPage: a month grid, no posts and no controls. */ -const ArchiveScreen = ({ device }: { device: DeviceName }) => ( - -
- - Sources / XDA Developers / Best of - -
-

- Best of XDA Developers — Archive -

- -
- -
- {['2026', '2025'].map((year) => ( -
-

- {year} -

-
- {['January', 'February', 'March', 'April'].map((month) => ( - - - {month} - - - 24 posts - - - ))} -
-
- ))} -
-
-
-); - -/* -------------------------------------------------------------------- page */ - -const Rails = ({ - Screen, -}: { - Screen: React.ComponentType<{ device: DeviceName }>; -}) => ( - - - - - -); - -const Directories = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Topic & directory pages', - component: Directories, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx index f6ed7e190c..4b3b7e4d2f 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx @@ -96,8 +96,8 @@ const PAGES: React.ReactNode[][] = [ ], [ 'Topic & directory pages', - '#6357 #6363 #6364 #6359', - 'Tags, sources, squads, collections — sharing beside a primary CTA without competing with it', + '#6566', + 'Shipped: the live tag, source, squad and best-of pages are the reference, so they have no mockup here', ], [ 'Invite & feed export', From 24e8cac8b71c52973b95048e6eae877ba6e0d133 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:31:04 +0300 Subject: [PATCH 14/17] chore(sources): import the share campaign from its source file SourceActions and TagTopicPage read ReferralCampaignKey, which the copy link's shareProps now depends on, through the hooks barrel. Import it from lib/referral, and the hook beside it from its own file. --- packages/shared/src/components/sources/SourceActions/index.tsx | 3 ++- packages/shared/src/components/tags/TagTopicPage.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/sources/SourceActions/index.tsx b/packages/shared/src/components/sources/SourceActions/index.tsx index b03dbbde40..d013c2dfbf 100644 --- a/packages/shared/src/components/sources/SourceActions/index.tsx +++ b/packages/shared/src/components/sources/SourceActions/index.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { useRouter } from 'next/router'; import { ButtonVariant } from '../../buttons/common'; import type { Source } from '../../../graphql/sources'; -import { ReferralCampaignKey, useSourceActions } from '../../../hooks'; +import { useSourceActions } from '../../../hooks/source/useSourceActions'; +import { ReferralCampaignKey } from '../../../lib/referral'; import SourceActionsNotify from './SourceActionsNotify'; import SourceActionsBlock from './SourceActionsBlock'; import SourceActionsFollow from './SourceActionsFollow'; diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index dfb5db115e..2b5329ee40 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -34,7 +34,8 @@ import { } from '../icons'; import type { TagsData } from '../../graphql/feedSettings'; import useFeedSettings from '../../hooks/useFeedSettings'; -import { ReferralCampaignKey, useFeedLayout } from '../../hooks'; +import { useFeedLayout } from '../../hooks/useFeedLayout'; +import { ReferralCampaignKey } from '../../lib/referral'; import type { SourceTooltip } from '../../graphql/sources'; import { SOURCES_BY_TAG_QUERY } from '../../graphql/sources'; import type { Connection } from '../../graphql/common'; From afcc29bbefc86230e7a43d7c986463d24728baa9 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:22:15 +0300 Subject: [PATCH 15/17] fix(squads): offer the copy link on the squad directory list rows Below the MobileL breakpoint the squad directory renders SquadList rows instead of the grid cards, and those rows only had Join, so phones had no copy link on /squads/discover even though touch was the reason for the placement. The same rows back the featured, category and my squads pages on phones. The row now carries the same CopyLinkButton as the grid cards (squad directory origin, share source event, share source campaign). It sits above the row's overlay link, so a tap copies instead of opening the squad, and it only hides behind hover for a fine pointer on laptop and up, like the unfeatured grid card. The text column now shrinks with min-w-0 instead of a max width sized for a single button. --- .../components/cards/squad/SquadList.spec.tsx | 39 ++++++++++++++++- .../src/components/cards/squad/SquadList.tsx | 42 ++++++++++++++----- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/cards/squad/SquadList.spec.tsx b/packages/shared/src/components/cards/squad/SquadList.spec.tsx index bcf0976129..3acce269e6 100644 --- a/packages/shared/src/components/cards/squad/SquadList.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.spec.tsx @@ -1,5 +1,5 @@ import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import nock from 'nock'; @@ -21,6 +21,9 @@ import { CONTENT_PREFERENCE_STATUS_QUERY, ContentPreferenceType, } from '../../../graphql/contentPreference'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../../lib/log'; +import { ShareProvider } from '../../../lib/share'; const squadsList = [generateTestSquad()]; const members = generateMembersList(); @@ -81,6 +84,40 @@ it('should render the component and member count when members are provided', () expect(memberCount.innerHTML).toEqual(`${length} members`); }); +it('copies the squad link from the row without following the row link', () => { + const writeText = jest.fn().mockResolvedValue(undefined); + const logEvent = jest.fn(); + const onRowLinkClick = jest.fn(); + Object.assign(navigator, { clipboard: { writeText } }); + render( + + + , + ); + screen + .getByTitle(admin.source.name) + .addEventListener('click', onRowLinkClick); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(writeText).toHaveBeenCalledWith( + `${admin.source.permalink}?userid=${loggedUser.id}&cid=share_source`, + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareSource, + target_id: admin.source.id, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.SquadDirectory, + }), + }); + expect(onRowLinkClick).not.toHaveBeenCalled(); +}); + it('should render the component with a view squad button', async () => { mockGraphQL({ request: { diff --git a/packages/shared/src/components/cards/squad/SquadList.tsx b/packages/shared/src/components/cards/squad/SquadList.tsx index a5f4ced528..c301a8e545 100644 --- a/packages/shared/src/components/cards/squad/SquadList.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.tsx @@ -11,13 +11,15 @@ import { Separator } from '../common/common'; import { largeNumberFormat } from '../../../lib'; import { CardLink } from '../common/Card'; import { SquadActionButton } from '../../squads/SquadActionButton'; -import { Origin } from '../../../lib/log'; +import { LogEvent, Origin } from '../../../lib/log'; import { Image, ImageType } from '../../image/Image'; -import { ButtonVariant } from '../../buttons/common'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; import type { Ad } from '../../../graphql/posts'; import { useSquadsDirectoryLogging } from './common/useSquadsDirectoryLogging'; import { AdViewability } from '../ad/common/AdViewability'; import { useScrambler } from '../../../hooks/useScrambler'; +import { CopyLinkButton } from '../../share/CopyLinkButton'; +import { ReferralCampaignKey } from '../../../lib/referral'; interface SquadListProps extends ComponentProps<'div'> { squad: Squad; @@ -37,6 +39,15 @@ export const SquadList = ({ const campaignId = ad?.data?.source?.flags?.campaignId; const { ref, onClickAd, onViewableAd } = useSquadsDirectoryLogging(ad); const promotedText = useScrambler('Promoted'); + const shareProps = { + text: `Check out the ${name} squad on daily.dev`, + link: permalink, + cid: ReferralCampaignKey.ShareSource, + logObject: () => ({ + event_name: LogEvent.ShareSource, + target_id: squad.id, + }), + }; return (
-
+
{name} @@ -80,14 +91,23 @@ export const SquadList = ({ )}
- +
+ + +
{children} {!!ad && }
From 04e140096922c89dd0ea7dd12b8e379be16bd6e9 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:22:15 +0300 Subject: [PATCH 16/17] fix(squads): show one link control in the squad header A member who can invite saw the existing "Invitation link" next to the new "Copy link", two controls for nearly the same thing. The copy link now renders only when the invitation link does not: for non-members of a public squad and for members without the invite permission. The invite permission check moves up from SquadInviteButton so both controls read the same condition. --- .../components/squads/SquadHeaderBar.spec.tsx | 31 ++++++++++++++ .../src/components/squads/SquadHeaderBar.tsx | 40 +++++++++---------- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx b/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx index 017aef7a88..4a513da347 100644 --- a/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx +++ b/packages/shared/src/components/squads/SquadHeaderBar.spec.tsx @@ -115,3 +115,34 @@ describe('Analytics button', () => { ).not.toBeInTheDocument(); }); }); + +describe('Link controls', () => { + it('should show only the invitation link to a member who can invite', () => { + const squad = generateTestSquad({ + currentMember: { + ...mock.squad.currentMember!, + permissions: [SourcePermissions.Invite], + }, + }); + renderComponent({ props: { squad } }); + + expect( + screen.getByRole('button', { name: 'Invitation link' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Copy link' }), + ).not.toBeInTheDocument(); + }); + + it('should show only the copy link to a non-member', () => { + const squad = generateTestSquad({ currentMember: undefined }); + renderComponent({ props: { squad } }); + + expect( + screen.getByRole('button', { name: 'Copy link' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Invitation link' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/squads/SquadHeaderBar.tsx b/packages/shared/src/components/squads/SquadHeaderBar.tsx index 4525176d54..79c6ada83c 100644 --- a/packages/shared/src/components/squads/SquadHeaderBar.tsx +++ b/packages/shared/src/components/squads/SquadHeaderBar.tsx @@ -118,14 +118,6 @@ const SquadInviteButton = ({ squad, ...props }: SquadBarButtonProps) => { - const canRender = useMemo(() => { - return verifyPermission(squad, SourcePermissions.Invite); - }, [squad]); - - if (!canRender) { - return null; - } - return (
From db5869b85b1bdabc25ef45c2a36479798d649088 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:22:16 +0300 Subject: [PATCH 17/17] fix(archive): offer the copy link on the best-of month and year pages The best-of index pages had a page-level copy link but the month and year pages they link to did not, for every scope. ArchiveFeedPage now renders the same control beside its heading. The scope to event and campaign mapping moves into ArchiveCopyLinkButton so both pages log the same scheme: share archive for the global scope, share tag or share source otherwise, with the archive index origin. --- .../archive/ArchiveCopyLinkButton.tsx | 54 +++++++++++++++++++ .../archive/ArchiveFeedPage.spec.tsx | 43 +++++++++++++++ .../components/archive/ArchiveFeedPage.tsx | 18 +++++-- .../components/archive/ArchiveIndexPage.tsx | 39 ++------------ 4 files changed, 115 insertions(+), 39 deletions(-) create mode 100644 packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx create mode 100644 packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx diff --git a/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx b/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx new file mode 100644 index 0000000000..123a5a93ce --- /dev/null +++ b/packages/shared/src/components/archive/ArchiveCopyLinkButton.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ArchiveScopeType } from '../../graphql/archive'; +import type { ArchiveScopeInfo } from '../../lib/archive'; +import { CopyLinkButton } from '../share/CopyLinkButton'; +import { LogEvent, Origin } from '../../lib/log'; +import { ReferralCampaignKey } from '../../lib/referral'; + +interface ArchiveCopyLinkButtonProps { + scopeType: ArchiveScopeInfo['scopeType']; + scopeId?: string; + text: string; +} + +const shareByScope: Record< + ArchiveScopeInfo['scopeType'], + { event: LogEvent; cid: ReferralCampaignKey } +> = { + [ArchiveScopeType.Global]: { + event: LogEvent.ShareArchive, + cid: ReferralCampaignKey.Generic, + }, + [ArchiveScopeType.Tag]: { + event: LogEvent.ShareTag, + cid: ReferralCampaignKey.ShareTag, + }, + [ArchiveScopeType.Source]: { + event: LogEvent.ShareSource, + cid: ReferralCampaignKey.ShareSource, + }, +}; + +export const ArchiveCopyLinkButton = ({ + scopeType, + scopeId, + text, +}: ArchiveCopyLinkButtonProps): ReactElement => { + const { event, cid } = shareByScope[scopeType]; + + return ( + ({ + event_name: event, + target_id: scopeId, + }), + }} + /> + ); +}; diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx new file mode 100644 index 0000000000..3e4a57559e --- /dev/null +++ b/packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import { ArchiveFeedPage } from './ArchiveFeedPage'; +import { ArchivePeriodType, ArchiveScopeType } from '../../graphql/archive'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +it('logs a copy link on a monthly best-of page as an archive share', () => { + const logEvent = jest.fn(); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareArchive, + target_id: undefined, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.ArchiveIndex, + }), + }); +}); diff --git a/packages/shared/src/components/archive/ArchiveFeedPage.tsx b/packages/shared/src/components/archive/ArchiveFeedPage.tsx index d9d00076de..b745b27ce9 100644 --- a/packages/shared/src/components/archive/ArchiveFeedPage.tsx +++ b/packages/shared/src/components/archive/ArchiveFeedPage.tsx @@ -4,13 +4,14 @@ import classNames from 'classnames'; import type { Archive, ArchiveItem } from '../../graphql/archive'; import { ArchivePeriodType } from '../../graphql/archive'; import type { ArchiveScopeInfo } from '../../lib/archive'; -import { getArchiveTitle, getArchiveIndexUrl } from '../../lib/archive'; +import { getArchiveIndexUrl, getArchivePeriodLabel } from '../../lib/archive'; import { ArchiveNavigation } from './ArchiveNavigation'; import { ArchivePostItem } from './ArchivePostItem'; import { ElementPlaceholder } from '../ElementPlaceholder'; import Link from '../utilities/Link'; import { ArrowIcon } from '../icons'; import { IconSize } from '../Icon'; +import { ArchiveCopyLinkButton } from './ArchiveCopyLinkButton'; interface ArchiveFeedPageProps { scopeType: ArchiveScopeInfo['scopeType']; @@ -83,7 +84,7 @@ export function ArchiveFeedPage({ isLoading, className, }: ArchiveFeedPageProps): ReactElement { - const title = getArchiveTitle({ + const periodLabel = getArchivePeriodLabel({ periodType, periodStart: periodType === ArchivePeriodType.Month && month @@ -104,9 +105,16 @@ export function ArchiveFeedPage({ )} > {/* Header */} -

- Best of {scopeName} — {title.replace('Best of ', '')} -

+
+

+ Best of {scopeName} — {periodLabel} +

+ +
{/* Top navigation */} = { - [ArchiveScopeType.Global]: { - event: LogEvent.ShareArchive, - cid: ReferralCampaignKey.Generic, - }, - [ArchiveScopeType.Tag]: { - event: LogEvent.ShareTag, - cid: ReferralCampaignKey.ShareTag, - }, - [ArchiveScopeType.Source]: { - event: LogEvent.ShareSource, - cid: ReferralCampaignKey.ShareSource, - }, -}; - export function ArchiveIndexPage({ archives, scopeType, @@ -181,7 +160,6 @@ export function ArchiveIndexPage({ className, }: ArchiveIndexPageProps): ReactElement { const groups = groupArchivesByYear(archives); - const { event: shareEvent, cid: shareCampaign } = shareByScope[scopeType]; return (
@@ -190,17 +168,10 @@ export function ArchiveIndexPage({

Best of {scopeName} — Archive

- ({ - event_name: shareEvent, - target_id: scopeId, - }), - }} +