From 856e7766daea97a2af00c1037dcaf661d92d35b5 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 10:57:22 +0300 Subject: [PATCH 1/3] docs(share): review the Share my feed placements on the app Ports the Share my feed placement review out of Storybook and onto /dev/share-my-feed, so the surfaces are read against real app CSS and the app's own theme switch rather than Storybook's. Covers the sharer (a share link in the feed settings General tab, where every other custom-feed-only block already lives, plus the top-20 text list as the fallback), the recipient's landing (preview, added, no session, feed limit), and the four decisions behind them. Controls are inert: this compares placement and copy, not behaviour. No production surface changes, and the page is gated to development with noindex/nofollow. Co-Authored-By: Claude Opus 5 --- packages/webapp/pages/dev/share-my-feed.tsx | 682 ++++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 packages/webapp/pages/dev/share-my-feed.tsx 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..20a04db79f2 --- /dev/null +++ b/packages/webapp/pages/dev/share-my-feed.tsx @@ -0,0 +1,682 @@ +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'; +import { isDevelopment } from '@dailydotdev/shared/src/lib/constants'; + +/** + * /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 ProductionGate = ({ children }: { children: ReactNode }) => { + if (!isDevelopment) { + return ( +
+

+ The Share my feed review page is only available in development. +

+
+ ); + } + 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; From eab95c93ee6d0d93a9d86d72e44c732588229529 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 11:41:17 +0300 Subject: [PATCH 2/3] docs(share): open the Share my feed review on branch previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page was gated to `isDevelopment`, which makes it unreachable on the branch preview deployment — the surface reviewers actually open. Swaps that for the host gate /dev/snapshot uses: block the canonical production hosts only, and allow localhost plus *.preview.app.daily.dev. Co-Authored-By: Claude Opus 5 --- packages/webapp/pages/dev/share-my-feed.tsx | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/webapp/pages/dev/share-my-feed.tsx b/packages/webapp/pages/dev/share-my-feed.tsx index 20a04db79f2..beb85e35e7d 100644 --- a/packages/webapp/pages/dev/share-my-feed.tsx +++ b/packages/webapp/pages/dev/share-my-feed.tsx @@ -22,7 +22,6 @@ import { StarIcon, TrashIcon, } from '@dailydotdev/shared/src/components/icons'; -import { isDevelopment } from '@dailydotdev/shared/src/lib/constants'; /** * /dev/share-my-feed — the Share my feed placement review, on the app rather @@ -522,12 +521,31 @@ const Rails = ({ spot }: { spot: Spot }) => ( ); -const ProductionGate = ({ children }: { children: ReactNode }) => { - if (!isDevelopment) { +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 only available in development. + The Share my feed review page is not available on production.

); @@ -539,7 +557,7 @@ const ShareMyFeedDevPage = (): ReactElement => { const [theme, setTheme] = useTheme('dark'); return ( - +
@@ -673,7 +691,7 @@ const ShareMyFeedDevPage = (): ReactElement => {
-
+ ); }; From 56c8afbb09b7b8cf36f8f92222063250992d5a1f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 12:08:11 +0300 Subject: [PATCH 3/3] feat(share): offer a share link in the feed settings General tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts the reviewed placement into the real modal instead of only in the mock: custom feeds get a "Share this feed" block between the default-feed toggle and the Happening Now placement, with the description carrying the part the link cannot say for itself — that opening it adds the feed to the recipient's own account. The `share_my_feed` flag defaults to false, so merging changes nothing on app.daily.dev. Branch previews force it on via `isPreviewHost`, since a preview runs as production and has no GrowthBook tooling to open a flag from the browser. The link is the shape the feature needs, not a working one: feeds are user-scoped, so resolving it for a non-owner is the backend work this is still waiting on. Co-Authored-By: Claude Opus 5 --- .../FeedSettingsGeneralSection.spec.tsx | 87 +++++++++++++++++++ .../sections/FeedSettingsGeneralSection.tsx | 64 +++++++++++++- packages/shared/src/lib/constants.ts | 12 +++ packages/shared/src/lib/featureManagement.ts | 6 ++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/components/feeds/FeedSettings/sections/FeedSettingsGeneralSection.spec.tsx 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);