diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.spec.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.spec.tsx new file mode 100644 index 00000000000..add869f265e --- /dev/null +++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.spec.tsx @@ -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( + + + + + , + ); + +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(); + }); +}); diff --git a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx index 4b65506a342..3975c852e90 100644 --- a/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx +++ b/packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.tsx @@ -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'; @@ -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 ( <>
@@ -187,6 +209,42 @@ export const FeedSettingsGeneralSection = (): ReactElement => { )}
)} + {canShareFeed && ( + <> + +
+
+ + Share this feed + + + Anyone who opens your link gets this feed added to their own, + tags and sources included. + +
+
+ + {shareFeedLink} + + +
+
+ + )}
diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 8e962b8f031..3ba0b24fed0 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -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; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 19040d3eff0..15c07fa559a 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -339,3 +339,9 @@ export const featurePlusSale = new Feature( // 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); diff --git a/packages/webapp/pages/dev/share-my-feed.tsx b/packages/webapp/pages/dev/share-my-feed.tsx new file mode 100644 index 00000000000..beb85e35e7d --- /dev/null +++ b/packages/webapp/pages/dev/share-my-feed.tsx @@ -0,0 +1,700 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AiIcon, + AlertIcon, + ArrowIcon, + BlockIcon, + CopyIcon, + EditIcon, + FilterIcon, + HashtagIcon, + LinkIcon, + MiniCloseIcon, + PlusIcon, + PlusUserIcon, + StarIcon, + TrashIcon, +} from '@dailydotdev/shared/src/components/icons'; + +/** + * /dev/share-my-feed — the Share my feed placement review, on the app rather + * than in Storybook, so the surfaces are read against real app CSS and the + * app's own theme switch. + * + * Controls are inert: this compares placement and copy, not behaviour. Nothing + * here is wired to a feed, and no production surface changes. + */ + +const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +const useTheme = (initial: 'dark' | 'light') => { + const [theme, setTheme] = useState<'dark' | 'light'>(initial); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + const root = document.documentElement; + if (theme === 'light') { + root.classList.add('light'); + } else { + root.classList.remove('light'); + } + }, [theme]); + return [theme, setTheme] as const; +}; + +/* ------------------------------------------------------------------ chrome */ + +type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +const DEVICES: Record = { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, +}; + +/** A surface drawn at one real viewport width, so density is comparable. */ +const Device = ({ + name, + children, +}: { + name: DeviceName; + children: ReactNode; +}) => ( +
+ + {name} · {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +const Rail = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + +const Variant = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: ReactNode; +}) => ( +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/* ----------------------------------------------------------- the sharer UI */ + +type Spot = 'today' | 'section' | 'list'; + +const MENU: [string, ReactElement][] = [ + ['General', ], + ['Tags', ], + ['Content sources', ], + ['Content preferences', ], + ['AI superpowers', ], + ['Filters', ], + ['Blocked content', ], +]; + +const Field = ({ + label, + value, + counter, +}: { + label: string; + value: string; + counter?: string; +}) => ( +
+ + {label} + {value} + + {counter && ( + {counter} + )} +
+); + +const Block = ({ + title, + description, + children, +}: { + title: string; + description?: string; + children?: ReactNode; +}) => ( +
+
+ {title} + {description && ( + {description} + )} +
+ {children} +
+); + +const Divider = () => ( +
+); + +/** + * FeedSettingsGeneralSection inside the feed settings modal. Feed name, + * emoji picker, default-feed toggle, Happening Now placement and delete are + * all `isCustomFeed`-gated already — the export belongs in the same set. + */ +const FeedSettingsScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+
+ + + My new feed + + + +
+ +
+ {device !== 'Mobile' && ( + + )} + +
+ + + + + + + + + + + + + {spot !== 'today' && ( + <> + + +
+
+ + dly.to/f/tomer-frontend + + +
+ {spot === 'list' && ( + + )} +
+
+ + )} + + + + +
+ + Default + + +
+
+ + + + + + +
+
+
+
+); + +/* -------------------------------------------------------- the recipient UI */ + +type LandingSpot = 'preview' | 'added' | 'signin' | 'limit'; + +const TAGS = [ + '#typescript', + '#react', + '#webdev', + '#css', + '#nextjs', + '#tooling', +]; + +const SAMPLE_POSTS = [ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', +]; + +/** + * The recipient's landing. `/feeds/new?entityId=&entityType=` already creates + * a feed and follows one entity into it — this is the same flow with a set + * rather than a single tag, so the precedent exists in FeedSettingsCreate. + */ +const LandingScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: LandingSpot; +}) => ( + +
+ + + {spot === 'added' && ( + <> +

+ Tomer's feed is yours now +

+

+ It is in your sidebar as a new feed. Rename it, add tags, or delete + it — from here it is your feed, not a copy of theirs. +

+ + )} + {spot === 'signin' && ( + <> +

+ Sign in to add Tomer's feed +

+

+ A feed lives in an account, so there is nowhere to put this one yet. + Sign in or sign up, then open the link again. +

+ + )} + {(spot === 'preview' || spot === 'limit') && ( + <> +

+ Tomer shared a feed with you +

+

+ 6 tags and 4 sources. Adding it creates a new feed in your account + called Tomer's feed. +

+ + )} + +
+ {TAGS.slice(0, device === 'Mobile' ? 4 : 6).map((tag) => ( + + {tag} + + ))} +
+ +
+ {spot === 'added' && ( + + )} + {spot === 'signin' && ( + + )} + {(spot === 'preview' || spot === 'limit') && ( + + )} +
+ + {spot === 'limit' && ( +
+ + + You've reached the maximum number of feeds. Delete one or + upgrade to Plus to add this feed. + +
+ )} + + {/* A sample of what the feed holds, not a reading surface: nothing here + is a link, so the only way in is to add the feed. */} +
+ {SAMPLE_POSTS.map((title) => ( +
+
+ + {title} + +
+ ))} +
+
+ +); + +const LandingRails = ({ spot }: { spot: LandingSpot }) => ( + + + + + +); + +const Rails = ({ spot }: { spot: Spot }) => ( + + + + + +); + +const useIsAllowedHost = () => { + const [allowed, setAllowed] = useState(true); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const { hostname } = window.location; + // Block the canonical production hosts only; allow localhost and the + // *.preview.app.daily.dev preview deployments so reviewers can open it. + setAllowed(hostname !== 'app.daily.dev' && hostname !== 'www.daily.dev'); + }, []); + + return allowed; +}; + +const HostGate = ({ children }: { children: ReactNode }) => { + const allowed = useIsAllowedHost(); + + if (!allowed) { + return ( +
+

+ The Share my feed review page is not available on production. +

+
+ ); + } + return <>{children}; +}; + +const ShareMyFeedDevPage = (): ReactElement => { + const [theme, setTheme] = useTheme('dark'); + + return ( + + +
+
+

Share my feed · placements

+ +
+ +
+
+

+ Share my feed +

+

+ Custom feeds only, and not a snapshot. A custom feed is something + you built — a name, an icon, a tag set — so the thing worth + sending is the feed itself, not a picture of its posts. Opening a + shared link adds the feed to the recipient's account, named + after whoever shared it. +

+

+ Revised from the sharing map (#6362), which had this down as + snapshot-only on the assumption there was nothing to link to. + There is, if the link creates something: Copy link leads, with a + text list as the fallback for anywhere a link will not do. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + ); +}; + +ShareMyFeedDevPage.getLayout = (page: ReactNode): ReactNode => page; + +export default ShareMyFeedDevPage;