Skip to content
Draft
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
7 changes: 6 additions & 1 deletion packages/shared/src/components/post/PostContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import React, { useRef } from 'react';
import dynamic from 'next/dynamic';
import type { Post } from '../../graphql/posts';
import { isVideoPost } from '../../graphql/posts';
Expand All @@ -27,6 +27,7 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle';
import { PostTagList } from './tags/PostTagList';
import PostSourceInfo from './PostSourceInfo';
import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate';
import { SelectionShareBar } from './SelectionShareBar';

type PostContentRawProps = Omit<PostContentProps, 'post'> & { post: Post };

Expand Down Expand Up @@ -136,8 +137,11 @@ export function PostContentRaw({

useTrackPostView({ post, shouldTrack: isVideoType });

const bodyRef = useRef<HTMLElement>(null);

const postMainColumn = (
<PostContainer
ref={bodyRef}
className={classNames('relative', className?.content)}
data-testid="postContainer"
>
Expand Down Expand Up @@ -259,6 +263,7 @@ export function PostContentRaw({
/>
)}
</BasePostContent>
<SelectionShareBar containerRef={bodyRef} post={post} />
</PostContainer>
);

Expand Down
81 changes: 81 additions & 0 deletions packages/shared/src/components/post/QuoteImageCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ReactElement } from 'react';
import React from 'react';
import LogoIcon from '../../svg/LogoIcon';
import LogoText from '../../svg/LogoText';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../typography/Typography';

export interface QuoteImageCardProps {
quote: string;
title: string;
sourceName?: string | null;
authorName?: string | null;
}

/**
* The 1200x630 quote card the screenshot service renders into a shareable
* image. Sized in fixed pixels because the output is a bitmap, not a
* responsive page.
*/
export const QuoteImageCard = ({
quote,
title,
sourceName,
authorName,
}: QuoteImageCardProps): ReactElement => {
const attribution = [authorName, sourceName].filter(Boolean).join(' · ');

return (
<div
className="flex h-[630px] w-[1200px] flex-col justify-between bg-background-default p-16"
data-testid="quoteImageCard"
>
<Typography
aria-hidden
bold
color={TypographyColor.Quaternary}
tag={TypographyTag.Span}
type={TypographyType.Tera}
className="h-16 leading-none"
>
</Typography>
<Typography
bold
tag={TypographyTag.P}
type={TypographyType.Mega1}
className="line-clamp-6 break-words"
>
{quote}
</Typography>
<div className="flex items-end justify-between gap-8">
<div className="flex min-w-0 flex-col gap-1">
<Typography
bold
color={TypographyColor.Secondary}
className="line-clamp-1"
type={TypographyType.Title2}
>
{title}
</Typography>
{!!attribution && (
<Typography
color={TypographyColor.Tertiary}
type={TypographyType.Title3}
>
{attribution}
</Typography>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<LogoIcon className={{ container: 'h-10' }} />
<LogoText className={{ container: 'h-10' }} />
</div>
</div>
</div>
);
};
168 changes: 168 additions & 0 deletions packages/shared/src/components/post/SelectionShareBar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { SelectionShareBar } from './SelectionShareBar';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
import { shouldUseNativeShare } from '../../lib/func';
import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification';
import type { Post } from '../../graphql/posts';

jest.mock('../../hooks/useTextSelectionShare', () => ({
__esModule: true,
useTextSelectionShare: jest.fn(),
}));

jest.mock('../../lib/func', () => {
const actual = jest.requireActual('../../lib/func');
return { __esModule: true, ...actual, shouldUseNativeShare: jest.fn() };
});

const useTextSelectionShareMock = useTextSelectionShare as jest.Mock;
const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock;
const writeText = jest.fn().mockResolvedValue(undefined);
const share = jest.fn().mockResolvedValue(undefined);
const clear = jest.fn();
const selection = 'shrinking the distance between a decision and its effect';

const post = {
id: 'post-1',
title: 'How to ship fast',
commentsPermalink: 'https://daily.dev/posts/how-to-ship-fast',
permalink: 'https://daily.dev/r/how-to-ship-fast',
} as unknown as Post;

const enabledGrowthBook = () =>
new GrowthBook({
features: {
sharing_visibility: { defaultValue: true },
share_text_selection: { defaultValue: true },
},
});

beforeEach(() => {
jest.clearAllMocks();
shouldUseNativeShareMock.mockReturnValue(false);
Object.assign(navigator, { clipboard: { writeText }, share });
useTextSelectionShareMock.mockReturnValue({
text: selection,
rect: { top: 400, bottom: 420, left: 100, right: 300 },
clear,
});
});

const renderComponent = (
gb = enabledGrowthBook(),
): RenderResult & { client: QueryClient } => {
const client = new QueryClient();
const containerRef = { current: document.createElement('div') };
document.body.appendChild(containerRef.current);

return {
client,
...render(
<TestBootProvider client={client} gb={gb}>
<SelectionShareBar containerRef={containerRef} post={post} />
</TestBootProvider>,
),
};
};

describe('SelectionShareBar flag gate', () => {
it('renders nothing and attaches no selection listeners when off', () => {
renderComponent(new GrowthBook());

expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument();
expect(useTextSelectionShareMock).not.toHaveBeenCalled();
});

it('renders nothing when only the per-surface flag is on', () => {
renderComponent(
new GrowthBook({
features: { share_text_selection: { defaultValue: true } },
}),
);

expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument();
expect(useTextSelectionShareMock).not.toHaveBeenCalled();
});
});

describe('SelectionShareBar actions', () => {
it('renders the share actions for a selection', () => {
renderComponent();

expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument();
expect(screen.getByLabelText('Copy link to this post')).toBeInTheDocument();
expect(screen.getByLabelText('Copy selected text')).toBeInTheDocument();
});

it('does not offer the quote image until the service renders it', () => {
renderComponent();

expect(
screen.queryByLabelText('Generate quote image'),
).not.toBeInTheDocument();
});

it('copies the post link and shows a toast', async () => {
const { client } = renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Copy link to this post'));
});

await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(post.commentsPermalink),
);
expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({
message: '✅ Copied link to clipboard',
});
});

it('copies the selected text and shows a toast', async () => {
const { client } = renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Copy selected text'));
});

await waitFor(() => expect(writeText).toHaveBeenCalledWith(selection));
expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({
message: '✅ Copied text to clipboard',
});
});

it('opens the native share sheet on mobile instead of copying', async () => {
shouldUseNativeShareMock.mockReturnValue(true);
renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Copy link to this post'));
});

await waitFor(() => expect(share).toHaveBeenCalled());
expect(share).toHaveBeenCalledWith({
text: `${selection}\n${post.commentsPermalink}`,
});
expect(writeText).not.toHaveBeenCalled();
});

it('dismisses on a click outside the bar', async () => {
renderComponent();

await act(async () => {
fireEvent.click(document.body);
});

expect(clear).toHaveBeenCalled();
});
});
Loading
Loading