From 06d90436f9bd27f9a963794a0b0d3bcd678ee791 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 11:16:11 +0300 Subject: [PATCH 1/5] feat(share): briefing copy actions + personalized digest share parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A presidential briefing is one of the most shareable things daily.dev produces, and there was almost nowhere to share it from: the briefing list had no copy affordance, the header had a single desktop-only copy-link button, the briefing body had none at all, and the personalized digest — which renders the same header component — had nothing. - `/briefing` rows gain copy-link and a share arrow. - The briefing body gains a copy control on every heading, bullet and paragraph. `BriefContent` wraps `Markdown`, appends a mount node per item and portals a control in, since the body is one sanitized-HTML blob. A heading copies its whole section; every copy appends the briefing link so a paste stays attributed. - The post header gains the same copy + share pair, outside the laptop-only wrapper so it works on mobile. - `DigestPostContent` passes `showShareButton`, reaching parity with the brief. One glyph per meaning across all of it: arrow opens a share surface, link copies the bare URL, stacked squares copy text with the link appended, page copies the summary. Every control confirms with the design system's success checkmark and a `ToastType.Success` toast. Also fixes a pre-existing layout bug: `base.css` sets a global `* { flex-shrink: 0 }`, so the row's `w-full` text column refused to shrink and painted trailing controls ~100px outside the card on every row. Gated by `share_briefing_digest`, which also requires the master `sharing_visibility` kill-switch. Flag-off is identical to PR 1. Co-Authored-By: Claude Opus 5 --- .../components/brief/BriefContent.spec.tsx | 261 ++++ .../src/components/brief/BriefContent.tsx | 267 +++++ .../components/brief/BriefListItem.spec.tsx | 70 +- .../src/components/brief/BriefListItem.tsx | 29 +- .../components/brief/BriefShareControls.tsx | 99 ++ .../post/brief/BriefPostContent.tsx | 8 +- .../brief/BriefPostHeaderActions.spec.tsx | 130 ++ .../post/brief/BriefPostHeaderActions.tsx | 32 +- .../post/digest/DigestPostContent.tsx | 21 +- .../components/share/ShareActions.spec.tsx | 2 +- .../src/components/share/ShareActions.tsx | 28 +- packages/shared/src/hooks/useCopyFeedback.ts | 33 + .../src/hooks/useShareBriefingDigest.ts | 16 + packages/shared/src/lib/featureManagement.ts | 10 + packages/shared/src/lib/share.ts | 1 + packages/storybook/package.json | 1 + .../components/BriefSharing.stories.tsx | 1048 +++++++++++++++++ pnpm-lock.yaml | 3 + 18 files changed, 2028 insertions(+), 31 deletions(-) create mode 100644 packages/shared/src/components/brief/BriefContent.spec.tsx create mode 100644 packages/shared/src/components/brief/BriefContent.tsx create mode 100644 packages/shared/src/components/brief/BriefShareControls.tsx create mode 100644 packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx create mode 100644 packages/shared/src/hooks/useCopyFeedback.ts create mode 100644 packages/shared/src/hooks/useShareBriefingDigest.ts create mode 100644 packages/storybook/stories/components/BriefSharing.stories.tsx diff --git a/packages/shared/src/components/brief/BriefContent.spec.tsx b/packages/shared/src/components/brief/BriefContent.spec.tsx new file mode 100644 index 00000000000..d75c0e5db19 --- /dev/null +++ b/packages/shared/src/components/brief/BriefContent.spec.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BriefContent } from './BriefContent'; +import type { Post } from '../../graphql/posts'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ToastType } from '../../hooks/useToastNotification'; + +const mockDisplayToast = jest.fn(); +const mockLogEvent = jest.fn(); +const writeText = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + // Keep the real module's exports — the component imports `ToastType` from + // here too, and a bare factory would leave it undefined. + ...jest.requireActual('../../hooks/useToastNotification'), + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +// The real Markdown component sanitizes asynchronously and pulls in hover cards +// and the image lightbox; none of that is what these tests are about. +jest.mock('../Markdown', () => ({ + __esModule: true, + // eslint-disable-next-line react/prop-types + default: ({ content }: { content: string }) => ( + // eslint-disable-next-line react/no-danger +
+ ), +})); + +const post = { + id: 'brief-1', + title: 'Presidential briefing', + commentsPermalink: 'https://app.daily.dev/posts/brief-1', +} as Post; + +const contentHtml = ` +

The npm supply chain took another hit

+

Three packages shipped a malicious post-install script.

+
    +
  • What happened: a maintainer account was taken over.
  • +
  • What to do: rotate your CI credentials.
  • +
+

Rust 1.90 lands async closures

+

The feature has been in nightly for two years.

+`; + +const renderComponent = (showItemActions = true, html = contentHtml) => + render( + + + , + ); + +// Locate a control by the element it belongs to rather than by index — the DOM +// order of headings, paragraphs and bullets interleaves. +const controlIn = (selector: string, contains: string): HTMLElement => { + const host = Array.from( + document.querySelectorAll(selector), + ).find((element) => element.textContent?.includes(contains)); + + if (!host) { + throw new Error(`no ${selector} containing "${contains}"`); + } + + return host.querySelector('button') as HTMLElement; +}; + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +it('renders a copy control on every heading, bullet and paragraph', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + // 2 bullets + 2 paragraphs. + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4); +}); + +it('copies a single bullet with the briefing link appended', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('li', 'What happened')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `What happened: a maintainer account was taken over.\n\n${post.commentsPermalink}`, + ), + ); + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Copied to clipboard', + expect.objectContaining({ variant: ToastType.Success }), + ); +}); + +it('copies a standalone paragraph', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('p', 'Three packages')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `Three packages shipped a malicious post-install script.\n\n${post.commentsPermalink}`, + ), + ); +}); + +it('copies a section as its heading, prose and bullets, one bullet per line', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + fireEvent.click(controlIn('h2', 'npm supply chain')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + [ + 'The npm supply chain took another hit', + '', + 'Three packages shipped a malicious post-install script.', + '', + '- What happened: a maintainer account was taken over.', + '- What to do: rotate your CI credentials.', + '', + post.commentsPermalink, + ].join('\n'), + ), + ); +}); + +it('stops a section at the next heading', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + fireEvent.click(controlIn('h2', 'Rust 1.90')); + + await waitFor(() => expect(writeText).toHaveBeenCalled()); + expect(writeText.mock.calls[0][0]).not.toContain('npm supply chain'); +}); + +// The body is model-generated markdown: heading level and whether a section +// uses bullets or prose vary between briefings. Matching only h2/h3/li left +// briefs like this one with no controls at all. +it('attaches to other heading levels and to prose-only sections', async () => { + renderComponent( + true, + `

Top level

Lead paragraph.

Deep heading

More prose.

`, + ); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy section')).toHaveLength(2), + ); + expect(screen.getAllByLabelText('Copy item')).toHaveLength(2); +}); + +it('does not double up on a paragraph nested inside a list item', async () => { + renderComponent(true, `
  • Wrapped bullet text.

`); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(1), + ); +}); + +it('logs the copy with a content discriminator per item kind', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + fireEvent.click(controlIn('li', 'What happened')); + + expect(mockLogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + extra: expect.stringContaining(ShareProvider.CopyText), + }), + ); + expect(mockLogEvent.mock.calls[0][0].extra).toContain('brief_bullet'); +}); + +it('confirms with a success checkmark on the control that was clicked', async () => { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + const first = controlIn('li', 'What happened'); + const second = controlIn('li', 'What to do'); + fireEvent.click(first); + + // Only the clicked control confirms — the shared `useCopy` flag would have + // lit every control on the page at once. + await waitFor(() => + expect(first.querySelector('.text-status-success')).toBeInTheDocument(), + ); + expect(second.querySelector('.text-status-success')).not.toBeInTheDocument(); +}); + +it('clears the checkmark after the confirmation window', async () => { + jest.useFakeTimers(); + + try { + renderComponent(); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy item')).toHaveLength(4), + ); + const first = controlIn('li', 'What happened'); + + act(() => { + fireEvent.click(first); + }); + expect(first.querySelector('.text-status-success')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(first.querySelector('.text-status-success')).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } +}); + +it('renders no controls at all with the gate off', async () => { + renderComponent(false); + + await waitFor(() => + expect(screen.getByText('Rust 1.90 lands async closures')).toBeVisible(), + ); + expect(screen.queryByLabelText('Copy item')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Copy section')).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/brief/BriefContent.tsx b/packages/shared/src/components/brief/BriefContent.tsx new file mode 100644 index 00000000000..46b625da941 --- /dev/null +++ b/packages/shared/src/components/brief/BriefContent.tsx @@ -0,0 +1,267 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import Markdown from '../Markdown'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon, VIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import type { Post } from '../../graphql/posts'; +import { useCopyText } from '../../hooks/useCopy'; +import { useCopyFeedback } from '../../hooks/useCopyFeedback'; +import { ToastType } from '../../hooks/useToastNotification'; +import { useShareBriefingDigest } from '../../hooks/useShareBriefingDigest'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePostLogEvent } from '../../lib/feed'; +import { LogEvent } from '../../lib/log'; +import type { Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; + +// The briefing body is sanitized HTML from `Markdown`, so per-item controls +// can't be composed in JSX. A mount node is appended to each bullet and section +// heading instead, and the button is portalled into it. +const MOUNT_CLASS = 'brief-item-copy-mount'; +// Deliberately wide. The body is model-generated markdown, so the heading level +// and whether a section uses bullets or prose vary between briefings — matching +// only `h2`/`h3`/`li` meant a brief written as plain paragraphs got no controls +// at all, which reads as the feature being missing rather than not applicable. +const ITEM_SELECTOR = 'h1, h2, h3, h4, li, p'; +const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4']); + +// A paragraph nested inside a list item or a quote is part of that item, not an +// item of its own — it would get a second, redundant control. +const isNestedProse = (element: HTMLElement): boolean => + element.tagName === 'P' && !!element.closest('li, blockquote'); + +type BriefItemKind = 'bullet' | 'paragraph' | 'section'; + +const kindOf = (element: HTMLElement): BriefItemKind => { + if (HEADING_TAGS.has(element.tagName)) { + return 'section'; + } + + return element.tagName === 'LI' ? 'bullet' : 'paragraph'; +}; + +interface BriefItem { + mount: HTMLElement; + kind: BriefItemKind; + element: HTMLElement; +} + +const readText = (element: Element): string => { + const clone = element.cloneNode(true) as HTMLElement; + clone.querySelectorAll(`.${MOUNT_CLASS}`).forEach((node) => node.remove()); + + return (clone.textContent ?? '').replace(/\s+/g, ' ').trim(); +}; + +// Lists have to be read item by item — collapsing a whole `
    ` with +// `textContent` runs every bullet together into one unreadable paragraph. +const readBlock = (element: Element): string => { + if (element.tagName !== 'UL' && element.tagName !== 'OL') { + return readText(element); + } + + return Array.from(element.children) + .filter((child) => child.tagName === 'LI') + .map((item) => `- ${readText(item)}`) + .join('\n'); +}; + +// A section is its heading plus everything up to the next heading — copying only +// the heading text would paste a title with no substance. +const readSectionText = (heading: HTMLElement): string => { + const parts = [readText(heading)]; + let node = heading.nextElementSibling; + + while (node && !HEADING_TAGS.has(node.tagName)) { + parts.push(readBlock(node)); + node = node.nextElementSibling; + } + + return parts.filter(Boolean).join('\n\n'); +}; + +export interface BriefContentProps { + post: Post; + origin: Origin; + contentHtml: string; + className?: string; + /** + * Renders the per-item copy controls. Left undefined it resolves from the + * `share_briefing_digest` gate; pass it explicitly to pin a state in + * Storybook or tests, where GrowthBook is mocked. + */ + showItemActions?: boolean; +} + +export const BriefContent = ({ + post, + origin, + contentHtml, + className, + showItemActions, +}: BriefContentProps): ReactElement => { + const containerRef = useRef(null); + const [items, setItems] = useState([]); + const isShareEnabled = useShareBriefingDigest(); + const showItems = showItemActions ?? isShareEnabled; + const [, copyText] = useCopyText(); + const [copiedKey, markCopied] = useCopyFeedback(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + + const scan = useCallback(() => { + const root = containerRef.current; + + if (!root) { + return; + } + + const next = Array.from(root.querySelectorAll(ITEM_SELECTOR)) + .filter((element) => !isNestedProse(element) && !!readText(element)) + .map((element) => { + // Walk the children rather than using `:scope >` — the Tailwind group + // class added below contains a `/`, which some selector engines (jsdom's + // included) fail to escape when they expand `:scope`. + const existing = Array.from(element.children).find((child) => + child.classList.contains(MOUNT_CLASS), + ) as HTMLElement | undefined; + const mount = existing ?? document.createElement('span'); + + if (!existing) { + mount.className = MOUNT_CLASS; + element.appendChild(mount); + } + + element.classList.add('group/brief-item'); + + return { mount, element, kind: kindOf(element) }; + }); + + // Portalling into the mounts mutates the tree and re-triggers the observer; + // bail out when nothing actually changed so the loop settles. + setItems((prev) => + prev.length === next.length && + prev.every((item, index) => item.mount === next[index].mount) + ? prev + : next, + ); + }, []); + + useEffect(() => { + const root = containerRef.current; + + if (!root || !showItems) { + setItems((prev) => (prev.length ? [] : prev)); + return undefined; + } + + // `Markdown` sanitizes asynchronously (DOMPurify loads in one of its own + // effects), so the body lands on a later render of the *child* — which does + // not re-run this component's effects. Watching the subtree is the only + // reliable signal. `scan` reuses existing mounts and `setItems` bails when + // nothing changed, so the mutations it causes settle after one extra pass. + let scheduled = false; + const observer = new MutationObserver(() => { + if (scheduled) { + return; + } + + scheduled = true; + queueMicrotask(() => { + scheduled = false; + scan(); + }); + }); + + observer.observe(root, { childList: true, subtree: true }); + scan(); + + return () => observer.disconnect(); + }, [scan, showItems, contentHtml]); + + const onCopyItem = (item: BriefItem, key: string) => { + const text = + item.kind === 'section' + ? readSectionText(item.element) + : readText(item.element); + + markCopied(key); + + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { + provider: ShareProvider.CopyText, + origin, + content: `brief_${item.kind}`, + }, + }), + ); + + // `ToastType.Success` renders the design system's green checkmark, so the + // toast and the button's own confirmation say the same thing — no need for + // the ✅ the older copy strings prefix by hand. + copyText({ + textToCopy: `${text}\n\n${post.commentsPermalink ?? ''}`.trim(), + message: + item.kind === 'section' + ? 'Copied section to clipboard' + : 'Copied to clipboard', + variant: ToastType.Success, + }); + }; + + return ( +
    + + {items.map((item, index) => { + const key = `${item.kind}-${index}`; + const isCopied = copiedKey === key; + const label = item.kind === 'section' ? 'Copy section' : 'Copy item'; + + return createPortal( + +
    + ); +}; diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx index c4e7e582b15..a8b5bf5abc1 100644 --- a/packages/shared/src/components/brief/BriefListItem.spec.tsx +++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BriefListItem } from './BriefListItem'; import type { Post } from '../../graphql/posts'; import { LogEvent, Origin, TargetId } from '../../lib/log'; @@ -20,6 +21,30 @@ jest.mock('../../hooks/usePlusSubscription', () => ({ usePlusSubscription: () => ({ isPlus: true }), })); +const mockUseShareBriefingDigest = jest.fn(); + +jest.mock('../../hooks/useShareBriefingDigest', () => ({ + useShareBriefingDigest: () => mockUseShareBriefingDigest(), +})); + +const mockCopyLink = jest.fn(); + +jest.mock('../../hooks/useSharePost', () => ({ + useSharePost: () => ({ copyLink: mockCopyLink }), +})); + +jest.mock('../../hooks/useShareCopyIcon', () => ({ + useShareCopyIcon: () => false, +})); + +// ShareActions owns its own popover/native-share behaviour and is covered by +// its own spec; here we only care that the row exposes its trigger. +jest.mock('../share/ShareActions', () => ({ + ShareActions: ({ label }: { label: string }) => ( +
- + {isNotPlus && (
({ + useShareBriefingDigest: () => mockUseShareBriefingDigest(), +})); + +jest.mock('../../../hooks/useSharePost', () => ({ + useSharePost: () => ({ copyLink: mockCopyLink }), +})); + +jest.mock('../../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; + +const post = { + id: 'brief-1', + title: 'Presidential briefing', + summary: 'Rust 1.90 lands async closures.', + commentsPermalink: 'https://app.daily.dev/posts/brief-1', +} as Post; + +// Mirrors how BriefPostContent renders the header (share always requested) and +// how DigestPostContent renders it (share requested only behind the gate). +const renderBrief = (): RenderResult => + render( + + + , + ); + +const renderDigest = (): RenderResult => + render( + + + , + ); + +describe('BriefPostHeaderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); + mockUseShareBriefingDigest.mockReturnValue(false); + Object.assign(navigator, { clipboard: { writeText } }); + }); + + describe('with the share gate off', () => { + it('keeps the legacy copy-link button on the brief', () => { + renderBrief(); + + fireEvent.click(screen.getByLabelText('Copy link')); + + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + expect(screen.queryByLabelText('Share briefing')).not.toBeInTheDocument(); + }); + + it('leaves the digest post with no share affordance at all', () => { + renderDigest(); + + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Share briefing')).not.toBeInTheDocument(); + }); + }); + + describe('with the share gate on', () => { + beforeEach(() => mockUseShareBriefingDigest.mockReturnValue(true)); + + it('gives the brief both a direct copy-link button and the share popover', () => { + renderBrief(); + + expect(screen.getByLabelText('Share briefing')).toBeInTheDocument(); + + // Copy link keeps its own control rather than hiding inside the popover, + // so the most common action stays one tap. + fireEvent.click(screen.getByLabelText('Copy link')); + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + }); + + it('gives the digest post the same share affordance as the brief', () => { + renderDigest(); + + expect(screen.getByLabelText('Share briefing')).toBeInTheDocument(); + }); + + it('taps straight through to the native share sheet on mobile', async () => { + useViewSizeMock.mockReturnValue(false); + Object.assign(navigator, { share, maxTouchPoints: 5 }); + + renderDigest(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Share briefing')); + }); + + await waitFor(() => expect(share).toHaveBeenCalled()); + expect(writeText).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d31a26c0abb..5f47be1a2c0 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -6,9 +6,12 @@ import type { PostHeaderActionsProps } from '../common'; import Link from '../../utilities/Link'; import { Button, ButtonSize } from '../../buttons/Button'; import { settingsUrl } from '../../../lib/constants'; -import { CopyIcon, LinkIcon, SettingsIcon } from '../../icons'; -import { useSharePost } from '../../../hooks/useSharePost'; -import { useShareCopyIcon } from '../../../hooks/useShareCopyIcon'; +import { SettingsIcon } from '../../icons'; +import { useShareBriefingDigest } from '../../../hooks/useShareBriefingDigest'; +import { + BriefCopyLinkButton, + BriefShareControls, +} from '../../brief/BriefShareControls'; import type { Origin } from '../../../lib/log'; const Container = classed('div', 'flex flex-row items-center'); @@ -27,18 +30,27 @@ export const BriefPostHeaderActions = ({ origin: Origin; showShareButton?: boolean; }): ReactElement => { - const { copyLink } = useSharePost(origin); - const showCopyIcon = useShareCopyIcon(); + const isShareEnabled = useShareBriefingDigest(); return ( + {/* Rendered outside the laptop-only wrapper: sharing a briefing is at + least as valuable on mobile, where the share arrow taps straight + through to the native sheet. */} + {showShareButton && isShareEnabled && ( + + )}
- {showShareButton && ( -