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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { render, screen } from '@testing-library/react';
import { TestBootProvider } from '../../../../../__tests__/helpers/boot';
import defaultUser from '../../../../../__tests__/fixture/loggedUser';
import { defaultQueryClientTestingConfig } from '../../../../../__tests__/helpers/tanstack-query';
import { FeedSettingsEditContext } from '../FeedSettingsEditContext';
import type { FeedSettingsEditContextValue } from '../types';
import { FeedType } from '../../../../graphql/feed';
import { featureShareMyFeed } from '../../../../lib/featureManagement';
import { FeedSettingsGeneralSection } from './FeedSettingsGeneralSection';

jest.mock('../../../../hooks/useFeedSettings', () => ({
__esModule: true,
default: jest.fn(() => ({ isLoading: false })),
}));

const SHARE_HEADING = 'Share this feed';

const getGrowthBook = (shareMyFeed: boolean): GrowthBook => {
const gb = new GrowthBook();
gb.setFeatures({ [featureShareMyFeed.id]: { defaultValue: shareMyFeed } });

return gb;
};

const renderComponent = ({
type = FeedType.Custom,
shareMyFeed = false,
}: { type?: FeedType; shareMyFeed?: boolean } = {}) =>
render(
<TestBootProvider
client={new QueryClient(defaultQueryClientTestingConfig)}
auth={{ user: defaultUser }}
gb={getGrowthBook(shareMyFeed)}
>
<FeedSettingsEditContext.Provider
value={
{
feed: { id: 'f1', type, flags: { name: 'My feed' } },
data: { name: 'My feed' },
setData: jest.fn(),
onSubmit: jest.fn(),
isSubmitPending: false,
onDelete: jest.fn(),
deleteStatus: 'idle',
onTagClick: jest.fn(),
onDiscard: jest.fn(),
isDirty: false,
onBackToFeed: jest.fn(),
editFeedSettings: jest.fn(),
isNewFeed: false,
} as unknown as FeedSettingsEditContextValue
}
>
<FeedSettingsGeneralSection />
</FeedSettingsEditContext.Provider>
</TestBootProvider>,
);

describe('FeedSettingsGeneralSection', () => {
it('should not offer sharing while the flag is off', () => {
renderComponent();

expect(screen.queryByText(SHARE_HEADING)).not.toBeInTheDocument();
});

it('should offer sharing on a custom feed when the flag is on', async () => {
renderComponent({ shareMyFeed: true });

expect(await screen.findByText(SHARE_HEADING)).toBeInTheDocument();
expect(
await screen.findByRole('button', { name: 'Copy link' }),
).toBeInTheDocument();
});

it('should not offer sharing on the main feed, which nobody built', async () => {
renderComponent({ type: FeedType.Main, shareMyFeed: true });

// The default-feed block is main-feed only, so the section has rendered.
expect(
await screen.findByText('Set as your default feed'),
).toBeInTheDocument();
expect(screen.queryByText(SHARE_HEADING)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import type { ReactElement } from 'react';
import React, { useContext } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import Link from '../../../utilities/Link';
import { FeedSettingsEditContext } from '../FeedSettingsEditContext';
import { Button } from '../../../buttons/Button';
import { ButtonSize, ButtonVariant } from '../../../buttons/common';
import { LockIcon, StarIcon, TrashIcon, VIcon } from '../../../icons';
import { LinkIcon, LockIcon, StarIcon, TrashIcon, VIcon } from '../../../icons';
import {
Typography,
TypographyType,
TypographyColor,
} from '../../../typography/Typography';
import { webappUrl } from '../../../../lib/constants';
import { isPreviewHost, webappUrl } from '../../../../lib/constants';
import { featureShareMyFeed } from '../../../../lib/featureManagement';
import { useConditionalFeature } from '../../../../hooks/useConditionalFeature';
import { useCopyLink } from '../../../../hooks/useCopy';
import { TextField } from '../../../fields/TextField';
import { EmojiPicker } from '../../../fields/EmojiPicker';
import { Divider } from '../../../utilities';
Expand Down Expand Up @@ -54,6 +57,25 @@ export const FeedSettingsGeneralSection = (): ReactElement => {
? user.defaultFeedId === null
: user.defaultFeedId === feed.id;

const { value: shareMyFeedFlag } = useConditionalFeature({
feature: featureShareMyFeed,
shouldEvaluate: isCustomFeed,
});
// After mount, not during render: the server cannot know the host the page
// will be served from, and disagreeing with it would break hydration.
const [isPreview, setIsPreview] = useState(false);
useEffect(() => {
setIsPreview(isPreviewHost());
}, []);
const canShareFeed = isCustomFeed && (shareMyFeedFlag || isPreview);

// Feeds are user-scoped, so nothing resolves this route for a non-owner yet:
// the shareable token is backend work the flag is waiting on.
const shareFeedLink = feed?.id
? `${webappUrl}feeds/shared/${feed.id}`
: undefined;
const [, copyShareFeedLink] = useCopyLink(() => shareFeedLink);

return (
<>
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -187,6 +209,42 @@ export const FeedSettingsGeneralSection = (): ReactElement => {
)}
</div>
)}
{canShareFeed && (
<>
<Divider className="my-1 bg-border-subtlest-tertiary" />
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Typography bold type={TypographyType.Body}>
Share this feed
</Typography>
<Typography
type={TypographyType.Callout}
color={TypographyColor.Tertiary}
>
Anyone who opens your link gets this feed added to their own,
tags and sources included.
</Typography>
</div>
<div className="flex w-full items-center gap-2 rounded-14 border border-border-subtlest-secondary px-3 py-2 tablet:max-w-70">
<Typography
className="min-w-0 flex-1 truncate"
type={TypographyType.Body}
>
{shareFeedLink}
</Typography>
<Button
type="button"
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
icon={<LinkIcon />}
onClick={() => copyShareFeedLink()}
>
Copy link
</Button>
</div>
</div>
</>
)}
<Divider className="my-1 bg-border-subtlest-tertiary" />
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export const isTesting =
process.env.NODE_ENV === 'test' || (!isDevelopment && !isProduction);
export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true';

/**
* Branch preview deployments, e.g. my-branch.preview.app.daily.dev. They run
* NODE_ENV=production against the production API, so neither `isDevelopment`
* nor GrowthBook's dev tools are available to open a flag for review — the
* host is the only thing that distinguishes them from app.daily.dev.
*/
export const PREVIEW_HOST_SUFFIX = '.preview.app.daily.dev';

export const isPreviewHost = (): boolean =>
typeof window !== 'undefined' &&
window.location.hostname.endsWith(PREVIEW_HOST_SUFFIX);

export const isBrave = (): boolean => {
if (typeof window === 'undefined' || !window.Promise) {
return false;
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,9 @@ export const featurePlusSale = new Feature<PlusSaleConfig>(
// warning, bad creative or revenue anomaly can be stopped without a deploy
// and an ISR revalidation cycle. Never ramp or target with this flag.
export const featureReadAdsense = new Feature('read_adsense', true);

// Sharing a custom feed: a link in the feed settings General tab that adds the
// feed to whoever opens it. Experiment default is the off state — the rollout
// is a GrowthBook decision. Branch previews force it on (see `isPreviewHost`),
// because a preview runs as production and has no way to open a flag.
export const featureShareMyFeed = new Feature('share_my_feed', false);
Loading
Loading