Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
219fa87
feat: add Make this tab yours customizer sidebar
tsahimatsliah Apr 23, 2026
0a90ada
feat: only auto-open customizer for users <14 days old
tsahimatsliah Apr 23, 2026
006906b
feat: show floating Customize button for all users
tsahimatsliah Apr 23, 2026
e4dae80
feat(newtab-customizer): sync header/feedback/feed with sidebar
tsahimatsliah Apr 23, 2026
95d192d
feat(newtab-customizer): focus mode, DND presets, more toggles, reset
tsahimatsliah Apr 23, 2026
8996d13
feat(newtab): multi-mode new tab experience (Zen, Focus, Discover)
tsahimatsliah Apr 23, 2026
03068f2
feat(newtab): make mode switch actually change the main area
tsahimatsliah Apr 23, 2026
cf5ec05
refactor(newtab-customizer): polish sidebar UX/UI pass
tsahimatsliah Apr 24, 2026
a65c983
refactor(newtab-customizer): reorder modes, simplify shortcuts, redes…
tsahimatsliah Apr 24, 2026
9f79752
feat(newtab-customizer): first-session welcome + zen/shortcuts polish
tsahimatsliah Apr 24, 2026
0593eb4
feat(newtab-customizer): finalize sidebar polish + profile dropdown i…
tsahimatsliah Apr 27, 2026
6d04bf3
refactor(newtab-customizer): strip Zen mode and Focus experience scop…
tsahimatsliah Apr 27, 2026
30f3cca
fix(newtab-customizer): address review feedback for #5915
tsahimatsliah Apr 27, 2026
5692c5d
fix(newtab-customizer): unbreak lint_shared on the new specs
tsahimatsliah Apr 27, 2026
2f85808
fix(newtab-customizer): address second review pass
tsahimatsliah Apr 27, 2026
51e6451
fix(newtab-customizer): apply Slack thread feedback
tsahimatsliah Apr 27, 2026
5a4631e
fix(newtab-customizer): land panel open without entrance animation
tsahimatsliah Apr 27, 2026
11e0811
fix(newtab-customizer): polish customizer + focus section copy
tsahimatsliah Apr 27, 2026
c5f690e
fix(newtab-customizer): polish first-session sidebar experience
tsahimatsliah Apr 27, 2026
382baec
fix(newtab-customizer): harden sidebar interactions
tsahimatsliah Apr 27, 2026
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/extension/src/newtab/DndBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import {
} from '@dailydotdev/shared/src/components/buttons/Button';
import { MiniCloseIcon as XIcon } from '@dailydotdev/shared/src/components/icons';
import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext';
import { useFocusSchedule } from '@dailydotdev/shared/src/features/newTab/store/focusSchedule.store';

export default function DndBanner(): ReactElement {
const { onDndSettings } = useDndContext();
const { pauseFor } = useFocusSchedule();

const turnOff = () => onDndSettings(null);
const turnOff = () => {
pauseFor(null);
onDndSettings(null);
};

return (
<div className="relative z-popup flex w-full flex-col items-start bg-accent-onion-default py-3 pl-3 pr-12 typo-footnote laptop:fixed laptop:h-8 laptop:flex-row laptop:items-center laptop:justify-center laptop:p-0">
Expand Down
163 changes: 115 additions & 48 deletions packages/extension/src/newtab/MainFeedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@ import React, {
useMemo,
useState,
} from 'react';
import classNames from 'classnames';
import MainLayout from '@dailydotdev/shared/src/components/MainLayout';
import MainFeedLayout from '@dailydotdev/shared/src/components/MainFeedLayout';
import ScrollToTopButton from '@dailydotdev/shared/src/components/ScrollToTopButton';
import { getShouldRedirect } from '@dailydotdev/shared/src/components/utilities';
import dynamic from 'next/dynamic';
import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext';
import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search';
import { LogEvent } from '@dailydotdev/shared/src/lib/log';
import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext';
import { useFeedLayout } from '@dailydotdev/shared/src/hooks';
import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext';
import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext';
import useCustomDefaultFeed from '@dailydotdev/shared/src/hooks/feed/useCustomDefaultFeed';
import {
CustomizeNewTabSidebar,
CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX,
} from '@dailydotdev/shared/src/features/customizeNewTab/CustomizeNewTabSidebar';
import { useCustomizeNewTab } from '@dailydotdev/shared/src/features/customizeNewTab/useCustomizeNewTab';
import {
useCustomizerFirstSession,
useRightSidebarOffset,
} from '@dailydotdev/shared/src/features/customizeNewTab/store/rightSidebar.store';
import ShortcutLinks from './ShortcutLinks/ShortcutLinks';
import DndBanner from './DndBanner';
import { CompanionPopupButton } from '../companion/CompanionPopupButton';
Expand All @@ -31,6 +41,13 @@ const PostsSearch = dynamic(
),
);

const MainFeedLayout = dynamic(
() =>
import(
/* webpackChunkName: "mainFeedLayout" */ '@dailydotdev/shared/src/components/MainFeedLayout'
),
);

const DndModal = dynamic(
() => import(/* webpackChunkName: "dndModal" */ './DndModal'),
);
Expand Down Expand Up @@ -68,6 +85,7 @@ export default function MainFeedPage({
const { logEvent } = useLogContext();
const [isSearchOn, setIsSearchOn] = useState(false);
const { user, loadingUser } = useContext(AuthContext);
const { optOutCompanion, showFeedbackButton } = useSettingsContext();
const [feedName, setFeedName] = useState<string>(() =>
getInitialFeedName(initialPage),
);
Expand All @@ -76,6 +94,23 @@ export default function MainFeedPage({
useCompanionSettings();
const { isActive: isDndActive, showDnd, setShowDnd } = useDndContext();
const { isCustomDefaultFeed } = useCustomDefaultFeed();
const customizer = useCustomizeNewTab();
const customizerOffset = customizer.isOpen
? `${CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX}px`
: '0px';
// Same source the header & feedback pill read so any fixed control on the
// new tab can stay clear of the open panel without recomputing widths.
const rightSidebarOffset = useRightSidebarOffset();
// Mirror `FeedbackWidget`'s short-circuit during first-session
// onboarding so the scroll-to-top wrapper drops to the bottom rail
// instead of leaving a gap where the (now hidden) Feedback pill would
// have been.
const isCustomizerFirstSession = useCustomizerFirstSession();
const isFeedbackButtonRendered =
showFeedbackButton && !isCustomizerFirstSession;
const shortcutsSlot = shortcuts ?? (
<ShortcutLinks shouldUseListFeedLayout={shouldUseListFeedLayout} />
);

useLayoutEffect(() => {
if (!initialPage || !shouldInitializeCurrentPage) {
Expand Down Expand Up @@ -135,55 +170,87 @@ export default function MainFeedPage({

return (
<>
<div className="fixed bottom-0 left-0 z-2 w-full">
<ScrollToTopButton />
</div>
<MainLayout
mainPage
isNavItemsButton
activePage={activePage}
onLogoClick={onLogoClick}
onNavTabClick={onNavTabClick}
screenCentered={false}
customBanner={isDndActive && <DndBanner />}
additionalButtons={!loadingUser && <CompanionPopupButton />}
<div
className={classNames(
// Skip the padding transition on the very first paint so the
// first-session auto-open lands without any layout-shift —
// the panel + feed reach their resting positions in lockstep.
// Subsequent open / close still animate normally.
customizer.hasSettledInitialOpen &&
'transition-[padding] duration-200 ease-in-out',
)}
style={{ paddingRight: customizerOffset }}
>
<FeedLayoutProvider>
<MainFeedLayout
feedName={feedName}
isSearchOn={isSearchOn}
searchQuery={searchQuery}
onNavTabClick={onNavTabClick}
searchChildren={
<PostsSearch
onSubmitQuery={async (query, extraFlags) => {
logEvent({
event_name: LogEvent.SubmitSearch,
extra: JSON.stringify({
query,
provider: SearchProviderEnum.Posts,
...extraFlags,
}),
});

setSearchQuery(query);
}}
onFocus={() => {
logEvent({ event_name: LogEvent.FocusSearch });
}}
/>
}
shortcuts={
shortcuts ?? (
<ShortcutLinks
shouldUseListFeedLayout={shouldUseListFeedLayout}
/>
)
}
{/* Park the back-to-top icon just above the Feedback pill (when
visible) so they share the right rail without overlapping. With
no Feedback pill — either disabled in settings or hidden during
first-session onboarding — the icon drops to the corner instead
of floating over a gap. The inline `right` slides the wrapper
out from under the customizer panel when it opens, mirroring
`FeedbackWidget`. The transition is gated on
`hasSettledInitialOpen` for the same reason as the outer
wrapper above. */}
<div
className={classNames(
'fixed z-2',
isFeedbackButtonRendered ? 'bottom-20' : 'bottom-4',
)}
style={{
right: `calc(1rem + ${rightSidebarOffset}px)`,
transition: customizer.hasSettledInitialOpen
? 'right 200ms ease-in-out'
: undefined,
}}
>
<ScrollToTopButton
compact
className="!static !right-auto !top-auto"
/>
</FeedLayoutProvider>
<DndModal isOpen={showDnd} onRequestClose={() => setShowDnd(false)} />
</MainLayout>
</div>
<MainLayout
mainPage
isNavItemsButton
activePage={activePage}
onLogoClick={onLogoClick}
onNavTabClick={onNavTabClick}
screenCentered={false}
customBanner={isDndActive && <DndBanner />}
additionalButtons={
!loadingUser && !optOutCompanion && <CompanionPopupButton />
}
>
<FeedLayoutProvider>
<MainFeedLayout
feedName={feedName}
isSearchOn={isSearchOn}
searchQuery={searchQuery}
onNavTabClick={onNavTabClick}
searchChildren={
<PostsSearch
onSubmitQuery={async (query, extraFlags) => {
logEvent({
event_name: LogEvent.SubmitSearch,
extra: JSON.stringify({
query,
provider: SearchProviderEnum.Posts,
...extraFlags,
}),
});

setSearchQuery(query);
}}
onFocus={() => {
logEvent({ event_name: LogEvent.FocusSearch });
}}
/>
}
shortcuts={shortcutsSlot}
/>
</FeedLayoutProvider>
<DndModal isOpen={showDnd} onRequestClose={() => setShowDnd(false)} />
</MainLayout>
</div>
<CustomizeNewTabSidebar customizer={customizer} />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ const defaultSettings: RemoteSettings = {
optOutLevelSystem: false,
optOutQuestSystem: false,
optOutCompanion: true,
optOutCores: false,
optOutReputation: false,
autoDismissNotifications: true,
sortCommentsBy: SortCommentsBy.NewestFirst,
showFeedbackButton: true,
Expand Down
98 changes: 90 additions & 8 deletions packages/extension/src/newtab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@ import {
import { get as getCache } from 'idb-keyval';
import browser from 'webextension-polyfill';
import type { DndSettings } from '@dailydotdev/shared/src/contexts/DndContext';
import {
FOCUS_SCHEDULE_STORAGE_KEY,
isFocusActiveAt,
readFocusSchedule,
type FocusSchedule,
} from '@dailydotdev/shared/src/features/newTab/store/focusSchedule.store';
import {
NEW_TAB_MODE_STORAGE_KEY,
readNewTabMode,
type NewTabMode,
} from '@dailydotdev/shared/src/features/newTab/store/newTabMode.store';
import App from './App';
import { getDefaultLink } from './dnd';

declare global {
interface Window {
Expand All @@ -28,33 +40,103 @@ window.addEventListener(
},
);

const root = createRoot(document.getElementById('__next'));
const container = document.getElementById('__next');
if (!container) {
throw new Error('New tab root container is missing');
}
const root = createRoot(container);

const renderApp = (data?: BootCacheData) => {
root.render(<App localBootData={data} />);
};

const redirectApp = async (url: string) => {
const tab = await browser.tabs.getCurrent();
if (!tab.id) {
throw new Error('Unable to redirect new tab without a tab id');
}
window.stop();
await browser.tabs.update(tab.id, { url });
};

// Read & coerce the DnD setting. Older builds stored `expiration` as a Date
// (idb-keyval preserves it), but a stringified value can sneak in via manual
// edits or older code paths — be defensive so a malformed entry never traps
// the user on a blank page.
const isDndActive = (settings: DndSettings | null | undefined): boolean => {
if (!settings?.expiration) {
return false;
}
const expirationMs = new Date(settings.expiration).getTime();
if (Number.isNaN(expirationMs)) {
return false;
}
return expirationMs > Date.now();
};

// Read the user-set Focus configuration. `localStorage` is the canonical
// store written by `useNewTabMode` / `useFocusSchedule` — we read it first
// so a silent `mirrorToExtensionStorage` failure can't strand the redirect.
// `chrome.storage.local` is a fallback for the rare case where localStorage
// is unavailable (e.g. site data cleared but extension data preserved).
const resolveScheduledFocus = async (): Promise<boolean> => {
try {
const localMode = readNewTabMode();
const localSchedule = readFocusSchedule();
if (localMode === 'focus') {
return isFocusActiveAt(localSchedule);
}
// localStorage said `discover`, but check the extension mirror in case
// the page just opened and `localStorage` was cleared by the user
// outside of our flow.
const stored = await browser.storage.local.get([
FOCUS_SCHEDULE_STORAGE_KEY,
NEW_TAB_MODE_STORAGE_KEY,
]);
const mirroredMode = stored[NEW_TAB_MODE_STORAGE_KEY] as
| NewTabMode
| undefined;
const mirroredSchedule = stored[FOCUS_SCHEDULE_STORAGE_KEY] as
| FocusSchedule
| undefined;
if (mirroredMode !== 'focus' || !mirroredSchedule) {
return false;
}
return isFocusActiveAt(mirroredSchedule);
} catch {
return false;
}
};

(async () => {
const data = getLocalBootData();

if (data?.settings?.theme) {
applyTheme(themeModes[data.settings.theme]);
}

const source = window.location.href.split('source=')[1];
// Always render the app on any unexpected failure below — a blank new tab
// is the worst possible outcome, much worse than a missed redirect.
try {
const source = window.location.href.split('source=')[1];
if (source) {
renderApp(data ?? undefined);
return;
}

if (source) {
return renderApp(data);
}
const dnd = await getCache<DndSettings>('dnd').catch(() => null);
if (isDndActive(dnd) && dnd) {
await redirectApp(dnd.link);
return;
}

const dnd = await getCache<DndSettings>('dnd');
const isDnd = dnd?.expiration?.getTime() > new Date().getTime();
if (await resolveScheduledFocus()) {
await redirectApp(getDefaultLink());
return;
}

return isDnd ? redirectApp(dnd.link) : renderApp(data);
renderApp(data ?? undefined);
} catch {
renderApp(data ?? undefined);
}
})();
4 changes: 4 additions & 0 deletions packages/shared/__tests__/fixture/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const createTestSettings = (
toggleShowTopSites: jest.fn(),
autoDismissNotifications: true,
optOutCompanion: true,
optOutCores: false,
optOutReputation: false,
optOutReadingStreak: true,
optOutLevelSystem: false,
optOutQuestSystem: false,
Expand All @@ -29,6 +31,8 @@ export const createTestSettings = (
toggleShowFeedbackButton: jest.fn(),
toggleAutoDismissNotifications: jest.fn(),
toggleOptOutCompanion: jest.fn(),
toggleOptOutCores: jest.fn(),
toggleOptOutReputation: jest.fn(),
toggleOptOutReadingStreak: jest.fn(),
toggleOptOutLevelSystem: jest.fn(),
toggleOptOutQuestSystem: jest.fn(),
Expand Down
Loading
Loading