+
{canGoBack &&
}
{customBanner}
{isBannerAvailable &&
}
@@ -199,35 +248,74 @@ function MainLayoutComponent({
/>
)}
-
+ {!sidebarOwnsHeader && (
+
+ )}
{isAuthReady && showSidebar && (
)}
- {children}
+ {sidebarOwnsHeader ? (
+
+ {shouldShowTopHero && (
+
+ onEnableReadingReminder(NotificationCtaPlacement.TopHero)
+ }
+ onClose={() =>
+ onDismissReadingReminder(NotificationCtaPlacement.TopHero)
+ }
+ />
+ )}
+ {topBanner}
+
+
+ {children}
+
+
+ ) : (
+ children
+ )}
- {!hideFeedbackWidget &&
}
+ {!hideFeedbackWidget && !sidebarOwnsHeader &&
}
);
}
diff --git a/packages/shared/src/components/RouteProgressBar.module.css b/packages/shared/src/components/RouteProgressBar.module.css
new file mode 100644
index 00000000000..28b88b982bb
--- /dev/null
+++ b/packages/shared/src/components/RouteProgressBar.module.css
@@ -0,0 +1,12 @@
+@keyframes route-progress-slide {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(400%);
+ }
+}
+
+.bar {
+ animation: route-progress-slide 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
+}
diff --git a/packages/shared/src/components/RouteProgressBar.tsx b/packages/shared/src/components/RouteProgressBar.tsx
new file mode 100644
index 00000000000..e010e434ce9
--- /dev/null
+++ b/packages/shared/src/components/RouteProgressBar.tsx
@@ -0,0 +1,69 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import classNames from 'classnames';
+import styles from './RouteProgressBar.module.css';
+
+interface RouteProgressBarProps {
+ className?: string;
+}
+
+/**
+ * Thin indeterminate progress bar rendered at the top of the v2 floating
+ * card while Next.js is between `routeChangeStart` and `routeChangeComplete`.
+ *
+ * Without this, the v2 sidebar updates instantly on rail-icon click (the
+ * optimistic `pendingCategory` swap) but the page content can stay on the
+ * old route for a noticeable beat while the destination chunk loads —
+ * making the two halves of the screen feel out of sync. The progress bar
+ * signals "we heard you, the page is loading" so the gap reads as
+ * intentional rather than broken.
+ *
+ * The bar is absolute / pointer-events-none, so consumers just drop it
+ * inside their already-positioned container (e.g. the floating-card
+ * wrapper) and it floats at the top edge without affecting layout.
+ */
+export const RouteProgressBar = ({
+ className,
+}: RouteProgressBarProps): ReactElement | null => {
+ const router = useRouter();
+ const [isRouteChanging, setIsRouteChanging] = useState(false);
+
+ useEffect(() => {
+ const handleStart = () => setIsRouteChanging(true);
+ const handleEnd = () => setIsRouteChanging(false);
+
+ router.events.on('routeChangeStart', handleStart);
+ router.events.on('routeChangeComplete', handleEnd);
+ router.events.on('routeChangeError', handleEnd);
+
+ return () => {
+ router.events.off('routeChangeStart', handleStart);
+ router.events.off('routeChangeComplete', handleEnd);
+ router.events.off('routeChangeError', handleEnd);
+ };
+ }, [router.events]);
+
+ if (!isRouteChanging) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
index ed12993f232..51595a9191b 100644
--- a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
+++ b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
@@ -14,14 +14,17 @@ import {
DropdownMenuTrigger,
} from '../dropdown/DropdownMenu';
import { Button } from '../buttons/Button';
+import type { ButtonProps } from '../buttons/Button';
import type { MenuItemProps } from '../dropdown/common';
interface BookmarkFolderContextMenuProps {
folder: BookmarkFolder;
+ buttonProps?: Pick
, 'className' | 'size' | 'variant'>;
}
export const BookmarkFolderContextMenu = ({
folder,
+ buttonProps,
}: BookmarkFolderContextMenuProps): ReactElement => {
const { openModal, closeModal } = useLazyModal();
const { showPrompt } = usePrompt();
@@ -68,9 +71,9 @@ export const BookmarkFolderContextMenu = ({
}
/>
diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
index 5a238c5a371..f638245e522 100644
--- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
+++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
@@ -10,6 +10,7 @@ import {
ShieldPlusIcon,
ShieldWarningIcon,
} from '../icons';
+import type { IconSize } from '../Icon';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { usePlusSubscription, useClickbaitTries } from '../../hooks';
import { SidebarSettingsFlags } from '../../graphql/settings';
@@ -18,17 +19,23 @@ import type { Origin } from '../../lib/log';
import { LogEvent, TargetId } from '../../lib/log';
import { useActiveFeedContext } from '../../contexts/ActiveFeedContext';
import { useAuthContext } from '../../contexts/AuthContext';
+import { AuthTriggers } from '../../lib/auth';
import { webappUrl } from '../../lib/constants';
import { FeedSettingsMenu } from '../feeds/FeedSettings/types';
import { Tooltip } from '../tooltip/Tooltip';
import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature';
+import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant';
export const ToggleClickbaitShield = ({
origin,
buttonProps = {},
+ iconButtonProps = {},
+ iconSize,
}: {
origin: Origin;
buttonProps?: ButtonProps<'button'>;
+ iconButtonProps?: ButtonProps<'button'>;
+ iconSize?: IconSize;
}): ReactElement | null => {
const queryClient = useQueryClient();
const { queryKey: feedQueryKey } = useActiveFeedContext();
@@ -37,19 +44,29 @@ export const ToggleClickbaitShield = ({
const { flags, updateFlag } = useSettingsContext();
const [loading, setLoading] = useState(false);
const router = useRouter();
- const { user } = useAuthContext();
+ const { user, showLogin } = useAuthContext();
const { maxTries, hasUsedFreeTrial, triesLeft } = useClickbaitTries();
const isClickbaitShieldEnabled = flags?.clickbaitShieldEnabled ?? false;
const { value: isNewD1Experience } = useNewD1ExperienceFeature({
shouldEvaluate: !isPlus,
});
+ // v2 dual-sidebar laptop renders this button inside the page-header
+ // strip alongside MyFeedHeading; use the consistent Medium + Tertiary
+ // sizing so the whole strip reads as one button family.
+ const { isV2 } = useLayoutVariant();
+ const isV2Compact = isV2;
const commonIconProps: ButtonProps<'button'> = {
size: ButtonSize.Medium,
- variant: ButtonVariant.Float,
+ variant: isV2Compact ? ButtonVariant.Tertiary : ButtonVariant.Float,
iconSecondaryOnHover: true,
...buttonProps,
+ ...iconButtonProps,
};
+ const sizedIcon = (icon: ReactElement, className?: string) =>
+ iconSize
+ ? React.cloneElement(icon, { size: iconSize, className })
+ : React.cloneElement(icon, { className });
if (!isPlus) {
if (isNewD1Experience) {
@@ -68,14 +85,16 @@ export const ToggleClickbaitShield = ({
- ) : (
-
- )
+ hasUsedFreeTrial
+ ? sizedIcon( , 'text-accent-ketchup-default')
+ : sizedIcon( )
}
onClick={() => {
if (!user) {
+ showLogin({
+ trigger: AuthTriggers.MainButton,
+ options: { isLogin: false },
+ });
return;
}
router.push(
@@ -99,14 +118,19 @@ export const ToggleClickbaitShield = ({
- ) : (
-
- )
+ isClickbaitShieldEnabled
+ ? sizedIcon( , 'text-status-success')
+ : sizedIcon( )
}
loading={loading}
onClick={async () => {
+ if (!user) {
+ showLogin({
+ trigger: AuthTriggers.MainButton,
+ options: { isLogin: false },
+ });
+ return;
+ }
const newState = !isClickbaitShieldEnabled;
setLoading(true);
await updateFlag(
diff --git a/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx b/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx
new file mode 100644
index 00000000000..3c9edbfc099
--- /dev/null
+++ b/packages/shared/src/components/cards/brief/BriefShortcutButton.tsx
@@ -0,0 +1,101 @@
+import type { ReactElement } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
+import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { BriefIcon } from '../../icons';
+import { IconSize } from '../../Icon';
+import { useActions } from '../../../hooks';
+import { ActionType } from '../../../graphql/actions';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { useLogContext } from '../../../contexts/LogContext';
+import { LogEvent, TargetId } from '../../../lib/log';
+import { webappUrl } from '../../../lib/constants';
+import { AuthTriggers } from '../../../lib/auth';
+import { checkIsExtension } from '../../../lib/func';
+
+type BriefShortcutButtonProps = {
+ className?: string;
+};
+
+export const BriefShortcutButton = ({
+ className,
+}: BriefShortcutButtonProps): ReactElement | null => {
+ const { isLoggedIn, isAuthReady, user, showLogin } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const { isActionsFetched, checkHasCompleted } = useActions();
+ const impressionRef = useRef(false);
+
+ const hasGeneratedPreviously =
+ isActionsFetched && checkHasCompleted(ActionType.GeneratedBrief);
+ const targetHref = `${webappUrl}${
+ hasGeneratedPreviously ? 'briefing/generate' : 'briefing?generate=true'
+ }`;
+
+ useEffect(() => {
+ if (impressionRef.current || !isAuthReady || !isLoggedIn) {
+ return;
+ }
+
+ impressionRef.current = true;
+ logEvent({
+ event_name: LogEvent.ImpressionBrief,
+ target_id: TargetId.Header,
+ extra: JSON.stringify({
+ is_demo: !user?.isPlus,
+ brief_date: new Date(),
+ }),
+ });
+ }, [isAuthReady, isLoggedIn, logEvent, user?.isPlus]);
+
+ const onLoggedInClick = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.ClickBrief,
+ target_id: TargetId.Header,
+ extra: JSON.stringify({
+ is_demo: !user?.isPlus,
+ brief_date: new Date(),
+ }),
+ });
+ }, [logEvent, user?.isPlus]);
+
+ const commonButtonProps = {
+ variant: ButtonVariant.Tertiary,
+ size: ButtonSize.Small,
+ icon: (
+
+ ) as ReactElement,
+ className,
+ children: 'Generate brief',
+ };
+
+ // Render the button for logged-out users on the extension new tab so
+ // the strip looks the same; click opens the auth modal instead of
+ // navigating to the briefing flow. Webapp keeps existing behavior
+ // (logged-out users don't see the button there).
+ if (!isLoggedIn) {
+ if (!checkIsExtension()) {
+ return null;
+ }
+
+ return (
+
+ showLogin({
+ trigger: AuthTriggers.MainButton,
+ options: { isLogin: false },
+ })
+ }
+ />
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx
index 21a691f3c91..33a407a2681 100644
--- a/packages/shared/src/components/feedback/FeedbackWidget.tsx
+++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx
@@ -1,15 +1,30 @@
import type { ReactElement } from 'react';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { getDayOfYear } from 'date-fns';
import { Button, ButtonVariant, ButtonSize } from '../buttons/Button';
import { useAuthContext } from '../../contexts/AuthContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
+import { useLogContext } from '../../contexts/LogContext';
import { useViewSize, ViewSize } from '../../hooks/useViewSize';
import { useLazyModal } from '../../hooks/useLazyModal';
import { LazyModal } from '../modals/common/types';
import { ProfilePicture, ProfileImageSize } from '../ProfilePicture';
import { useCustomizeNewTab } from '../../features/customizeNewTab/CustomizeNewTabContext';
+import { IconSize } from '../Icon';
+import { MiniCloseIcon } from '../icons';
+import { LogEvent, TargetType } from '../../lib/log';
+
+interface FeedbackWidgetProps {
+ // `fixed` (default) — floating bottom-right pill (legacy/v1 chrome).
+ // `sidebar` — inline button rendered inside the v2 expanded sidebar
+ // panel, with a hover-revealed × to hide it (toggles
+ // `showFeedbackButton`).
+ // `support` — same inline button rendered inside the v2 rail support
+ // popover; not gated by `showFeedbackButton` so users can re-open the
+ // widget after dismissing it from the sidebar.
+ placement?: 'fixed' | 'sidebar' | 'support';
+}
const TEAM_MEMBERS = [
{
@@ -68,22 +83,39 @@ const getDailyTrio = (): ReadonlyArray<(typeof TEAM_MEMBERS)[number]> => {
return [0, 1, 2].map((i) => TEAM_MEMBERS[(dayOfYear + i) % len]);
};
-export function FeedbackWidget(): ReactElement | null {
+export function FeedbackWidget({
+ placement = 'fixed',
+}: FeedbackWidgetProps = {}): ReactElement | null {
const { user } = useAuthContext();
- const { showFeedbackButton } = useSettingsContext();
+ const { showFeedbackButton, toggleShowFeedbackButton } = useSettingsContext();
+ const { logEvent } = useLogContext();
const isMobile = useViewSize(ViewSize.MobileL);
const { openModal } = useLazyModal();
const dailyTrio = useMemo(getDailyTrio, []);
const [isCompact, setIsCompact] = useState(false);
const { panelWidth } = useCustomizeNewTab();
- // Only show for authenticated users on desktop when the setting is on.
- // Mobile feedback is handled by FooterPlusButton. Hide during the
- // panel rather than a competing pill in the corner.
- const isVisible = !!user && !isMobile && showFeedbackButton;
+ // Only show for authenticated users on desktop. The fixed/sidebar
+ // variants are additionally gated by the `showFeedbackButton` setting;
+ // the support variant lives inside the rail support popover and is
+ // always reachable so users can re-open the widget after hiding it
+ // from the sidebar.
+ const isSupport = placement === 'support';
+ const isSidebar = placement === 'sidebar';
+ const isInline = isSupport || isSidebar;
+ const isVisible = !!user && !isMobile && (isSupport || showFeedbackButton);
+
+ const onHideFeedbackButton = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.ChangeSettings,
+ target_type: TargetType.FeedbackButton,
+ target_id: 'hide',
+ });
+ return toggleShowFeedbackButton();
+ }, [logEvent, toggleShowFeedbackButton]);
useEffect(() => {
- if (!isVisible) {
+ if (!isVisible || isInline) {
return undefined;
}
const callback = () => {
@@ -94,12 +126,59 @@ export function FeedbackWidget(): ReactElement | null {
callback();
window.addEventListener('scroll', callback, { passive: true });
return () => window.removeEventListener('scroll', callback);
- }, [isVisible]);
+ }, [isVisible, isInline]);
if (!isVisible) {
return null;
}
+ if (isInline) {
+ return (
+
+
openModal({ type: LazyModal.Feedback })}
+ aria-label="Send feedback. Real people reply."
+ >
+
+ Feedback
+
+ Real people reply
+
+
+
+ {dailyTrio.map((member, index) => (
+
+ ))}
+
+
+
+ {isSidebar && (
+
+
+
+ )}
+
+ );
+ }
+
return (
,
string
> = {
@@ -115,7 +117,7 @@ const feedNameToHeading: Record<
popular: 'Popular',
upvoted: 'Most upvoted',
discussed: 'Best discussions',
- bookmarks: 'Bookmarks',
+ following: 'Following',
};
export const FeedContainer = ({
@@ -138,6 +140,8 @@ export const FeedContainer = ({
const { loadedSettings } = useContext(SettingsContext);
const { shouldUseListFeedLayout, isListMode } = useFeedLayout();
const isLaptop = useViewSize(ViewSize.Laptop);
+ const { isV2 } = useLayoutVariant();
+ const isV2Laptop = isV2;
const { feedName } = useActiveFeedNameContext();
const activeFeedName = feedName ?? SharedFeedPage.MyFeed;
const { isAnyExplore, isExplorePopular, isExploreLatest } = useFeedName({
@@ -275,21 +279,39 @@ export const FeedContainer = ({
>
{inlineHeader && header}
{topContent}
- {isSearch && !shouldUseListFeedLayout && (
-
- {!!actionButtons && (
-
- {actionButtons}
-
+ {isV2Laptop && !shouldUseListFeedLayout && !isExtension
+ ? // v2 grid pages render the shared page-header strip at the
+ // top of the floating card. `pageHeaderClassName` locks the
+ // height (min-h-14) and provides the bottom border so every
+ // page has the same vertical rhythm — content fills it
+ // when available, otherwise the feed heading takes the
+ // title slot.
+ (!!actionButtons || !!feedHeading) && (
+
+ {actionButtons || (
+
+ {feedHeading}
+
+ )}
+
+ )
+ : isSearch &&
+ !shouldUseListFeedLayout && (
+
+ {!!actionButtons && (
+
+ {actionButtons}
+
+ )}
+ {shortcuts}
+
)}
- {shortcuts}
-
- )}
(
@@ -298,21 +320,36 @@ export const FeedContainer = ({
'flex flex-col',
!disableListFrame &&
'rounded-16 border border-border-subtlest-tertiary tablet:mt-6',
+ // v2-only: clip hover backgrounds at the frame's rounded
+ // corners + swap the list-card top separator token to
+ // match the floating-card / card hierarchy.
+ !disableListFrame &&
+ isV2Laptop &&
+ 'overflow-hidden [&_article]:!border-border-subtlest-quaternary',
!disableListFrame && isSearch && 'mt-6',
!disableListFrame && !isLaptop && '!mt-2 border-0',
)}
>
- (
-
- {feedHeading}
- {component}
-
- )}
- >
- {actionButtons || null}
-
+ {isV2Laptop && isLaptop && (feedHeading || actionButtons) ? (
+ // v2: shared page-header strip inside the list-frame
+ // box. Same component as the grid-mode strip so both
+ // layouts have identical header treatment.
+
+ {actionButtons || undefined}
+
+ ) : (
+ (
+
+ {feedHeading}
+ {component}
+
+ )}
+ >
+ {actionButtons || null}
+
+ )}
{isExtension && shortcuts}
{child}
@@ -321,8 +358,31 @@ export const FeedContainer = ({